6 Commits

26 changed files with 1825 additions and 116 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.
- Automatische lokale Sitzungstitel, `/rename`, nummerierte `/sessions`,
`/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
+12 -3
View File
@@ -5,9 +5,9 @@ Javis ist ein langfristiges, lokal-first ausgerichtetes Projekt für einen pers
## Aktueller Stand
Der lokale Textchat funktioniert auf Windows mit Ollama und `qwen3:8b`.
Ein optionaler Gemini-Free-Provider ist auf einem Feature-Branch vorbereitet;
Datenschutzrouter, Nullkostenmodus und lokales Fallback sind automatisiert
getestet, aber noch nicht von Pascal mit einem echten Key abgenommen.
Gemini-Free-Hybridrouting, Datenschutz, Streaming und Sitzungsnavigation sind
implementiert. Zusätzlich steht ein erster PySide6-Desktopclient zur manuellen
Abnahme bereit.
Noch nicht implementiert sind Obsidian-Zugriff, Serverzugriff, Sprache, Mobile
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
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
Key in der Konfigurationsdatei:
+23 -24
View File
@@ -109,6 +109,14 @@ Rootserver:
- Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile.
Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown
wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück.
- Desktop-MVP auf `feat/desktop-ui` begonnen: PySide6, gemeinsamer CLI-/GUI-
Runtime-Builder, Qt-freier Controller und ruhiges Drei-Spalten-Grundgerüst vorhanden.
- Sitzungen, Suche, Umbenennen, Markdownanzeige, Qt-Streamingworker, sicherer
Abbruch, Modus und bereinigte Statusanzeige sind im Desktopclient verdrahtet.
- `ask` unterstützt `Einmal erlauben`, `Lokal beantworten` und `Abbrechen`;
`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
@@ -189,8 +197,12 @@ Letzter bestätigter Projektstand:
- uv-Lock und `uv sync --dev`: bestanden
- Python in `.venv`: 3.12.13
- Ruff in `.venv`: 0.16.0
- Unit-Tests: 91 bestanden; Navigation, Streaming, Migration, Datenschutz,
ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen abgedeckt
- Tests: 101 bestanden; zusätzlich Qt-Offscreen-Fenster, Dialogzuordnung und
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
- Ruff Lint: bestanden
- Ruff Formatprüfung: bestanden
@@ -235,29 +247,16 @@ Abnahmestatus:
## Git-Stand
- aktueller und geprüfter Branch: `main`
- stabiler Ausgangsstand: `main` bei `77b510b`
- medizinischer Datenschutz-Fix: `9148193`
- Provider- und CLI-Streaming: `00a5a6c`
- SQLite-Titel und Migration: `112ae6d`
- Sitzungsnavigation: `43c1f76`
- Dokumentation: `328dcd9`
- aktueller Arbeitsbranch: `feat/desktop-ui`
- stabiler Ausgangsstand: `main` bei `d3e85f2`
- GUI-Grundgerüst und Kernadapter: `1658e04`
- Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
- Tests, Startskript und Desktopdokumentation: `571969b`
- `feat/desktop-ui` bis `571969b` gepusht und mit Remote synchron verifiziert
- Chatkomfort-Merge nach `main`: `cbba102`
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
- konfliktfreier Merge nach `main`: `564a3fd`
- 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
- `main` erfolgreich zu `origin/main` gepusht; Remote-Tracking ist synchron
- Secret-Provider: `570e7fa`
- Datenschutz/Nullkosten: `286d96d`
- isolierter Gemini-Provider: `f3e1183`
- Hybridrouter: `e57186f`
- lokale Konfiguration: `ab6ea48`
- CLI-Verdrahtung: `af9426e`
- Startskript: `141ff2e`
- Resttests: `a0af15a`
- Routing-/Reporting-Korrektur: `7a14fe8`
- Gemini-/Datenschutz-Merge nach `main`: `564a3fd`
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
- `origin` verwendet HTTPS
- kein Force-Push und keine umgeschriebene Historie
@@ -289,8 +288,8 @@ Abnahmestatus:
## Nächster sinnvoller Auftrag
Chatkomfort ist implementiert, gemergt und auf `main` erneut geprüft. Nach dem
Push stoppen und mit Pascal erst später ein einzelnes nächstes Paket festlegen.
Desktop-MVP nach `docs/DESKTOP_UI.md` manuell durch Pascal abnehmen. Danach
stoppen; vor dieser Abnahme nicht nach `main` mergen und keine Folgephase beginnen.
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
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-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 |
| 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;
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
Listen, Laden per Nummer/ID-Präfix und begrenzte Suche in Titeln/Benutzertexten.
91 Unit-Tests, Ruff, Struktur-, Secret-, Gitignore- und Auditprüfungen sowie
echte Ollama- und Gemini-Streaming-Smokes sind erfolgreich.
Das Desktop-MVP auf `feat/desktop-ui` verwendet denselben Kern wie die CLI.
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
Der Chatkomfort-Branch ist nach `main` gemergt und dort erneut geprüft. Den
finalen Stand aus `docs/CHATGPT_HANDOFF.md` übernehmen und mit Pascal ein
einzelnes nächstes Paket festlegen; noch keine Folgephase automatisch beginnen.
Nur den Desktop-MVP nach `docs/DESKTOP_UI.md` manuell abnehmen. Erst danach mit
Pascal ein einzelnes nächstes Paket festlegen; keine Folgephase automatisch beginnen.
## Weiterhin nicht beginnen
+21 -3
View File
@@ -1,6 +1,6 @@
# 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
@@ -28,7 +28,7 @@ Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026.
Inhalte werden nicht wörtlich in automatische Titel übernommen
- echte CLI-Modellantwort sowie Laden und Fortsetzen derselben Sitzung in einem
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
geprüft: deutsche Antworten, Sitzungskontext, `/sessions`, `/exit`, Neustart,
`/load <UUID>`, vollständiger Verlauf und Fortsetzung
@@ -96,9 +96,26 @@ Stand: Chatkomfort-Feature vollständig automatisiert geprüft, 30.07.2026.
Obsidian-Adapter, Tools, Serverzugriff, Sprache, Mobile Client,
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
- aktueller Branch: `main`
- aktueller Branch: `feat/desktop-ui`
- Desktop-Basis: `main` bei `d3e85f2`
- GUI-Grundgerüst: `1658e04`
- Sitzungen/Streaming/Datenschutz: `cf35148`
- Tests/Startskript/Dokumentation: `571969b`
- `feat/desktop-ui` zu Gitea gepusht und synchron verifiziert
- Basis: `main` bei `77b510b`
- medizinisches Routing: `9148193`
- Streaming: `00a5a6c`
@@ -107,6 +124,7 @@ Living-Mind-Frontend.
- Dokumentation: `328dcd9`
- Feature-Branch zu `origin/feat/chat-comfort` gepusht
- Merge nach `main`: `cbba102`; 91 Tests und Ruff auf `main` erneut bestanden
- `main` bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
- vorheriger Gemini-/Datenschutz-Merge auf `main`: `564a3fd`
## Bekannte Einschränkung
+13
View File
@@ -0,0 +1,13 @@
Fatal Python error: Aborted
Current thread 0x000066dc (most recent call first):
File "D:\Javis-Projekt\src\javis\ui\main_window.py", line 301 in _generation_complete
File "D:\Javis-Projekt\src\javis\ui\app.py", line 44 in run_gui
File "D:\Javis-Projekt\src\javis\interface\cli.py", line 429 in main
File "D:\Javis-Projekt\.venv\Scripts\javis.exe\__main__.py", line 10 in <module>
File "<frozen runpy>", line 88 in _run_code
File "<frozen runpy>", line 198 in _run_module_as_main
Extension modules: charset_normalizer.md, charset_normalizer.cd, requests.packages.charset_normalizer.md, requests.packages.chardet.md, requests.packages.charset_normalizer.cd, requests.packages.chardet.cd, _cffi_backend, websockets.speedups, shiboken6.Shiboken, PySide6.QtCore, PySide6.QtGui, PySide6.QtWidgets (total: 12)
QThreadStorage: entry 1 destroyed before end of thread 0x28a793bea70
QThreadStorage: entry 0 destroyed before end of thread 0x28a793bea70
+2
View File
@@ -11,6 +11,7 @@ requires-python = ">=3.11"
dependencies = [
"google-genai>=2.6,<3",
"keyring>=25.7,<26",
"pyside6>=6.8,<7",
]
[project.scripts]
@@ -18,6 +19,7 @@ javis = "javis.interface.cli:main"
[dependency-groups]
dev = [
"pytest>=8,<9",
"ruff>=0.12,<1",
]
+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
+5
View File
@@ -113,7 +113,12 @@ if (-not (Test-OllamaReady)) {
Push-Location $repositoryRoot
try {
if ($env:JAVIS_START_INTERFACE -eq 'gui') {
& $resolvedUv run javis gui
}
else {
& $resolvedUv run javis chat
}
exit $LASTEXITCODE
}
finally {
+49 -5
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import threading
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import StrEnum
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
from javis.providers.base import (
@@ -18,7 +20,14 @@ from javis.providers.base import (
)
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter
ApprovalCallback = Callable[[PrivacyDecision], bool]
class ApprovalChoice(StrEnum):
ALLOW = "allow"
LOCAL = "local"
CANCEL = "cancel"
ApprovalCallback = Callable[[PrivacyDecision], ApprovalChoice | bool]
NoticeCallback = Callable[[str], None]
CloudProviderFactory = Callable[[], LocalModelProvider]
@@ -76,6 +85,8 @@ class HybridProvider:
self._max_cloud_input_chars = max_cloud_input_chars
self._max_cloud_context_messages = max_cloud_context_messages
self._cloud_session_disabled = False
self._approval_lock = threading.Lock()
self._next_approval: ApprovalChoice | None = None
self.last_route: RouteStatus | None = None
def supports_session(self, provider: str, model: str) -> bool:
@@ -89,6 +100,33 @@ class HybridProvider:
raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
self.mode = mode
def privacy_decision(self, text: str) -> PrivacyDecision:
"""Classify a prospective request without contacting a provider."""
return self._privacy_router.classify(
text,
cloud_requested=self.mode == "gemini",
)
def queue_approval(self, choice: ApprovalChoice) -> None:
"""Provide the GUI decision for exactly the next ASK request."""
if choice not in {ApprovalChoice.ALLOW, ApprovalChoice.LOCAL}:
raise ValueError("Nur Freigabe oder lokale Antwort kann vorgemerkt werden.")
with self._approval_lock:
self._next_approval = choice
def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice:
with self._approval_lock:
queued = self._next_approval
self._next_approval = None
if queued is not None:
return queued
result = self._approval_callback(decision)
if isinstance(result, bool):
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
if isinstance(result, ApprovalChoice):
return result
raise ValueError("Ungültiges Ergebnis der Cloudfreigabe.")
def chat(self, messages: list[ChatMessage]) -> str:
current_text = messages[-1].content if messages else ""
cloud_requested = self.mode == "gemini"
@@ -110,8 +148,11 @@ class HybridProvider:
approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision)
if not approved:
choice = self._approval_choice(decision)
if choice is ApprovalChoice.CANCEL:
raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.")
approved = choice is ApprovalChoice.ALLOW
if choice is ApprovalChoice.LOCAL:
return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True)
unavailable_reason = self._cloud_unavailable_reason()
@@ -194,8 +235,11 @@ class HybridProvider:
approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision)
if not approved:
choice = self._approval_choice(decision)
if choice is ApprovalChoice.CANCEL:
raise ResponseAbortedError("Anfrage vor der Cloudfreigabe abgebrochen.")
approved = choice is ApprovalChoice.ALLOW
if choice is ApprovalChoice.LOCAL:
yield from self._stream_local(
messages,
decision,
+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."""
+53
View File
@@ -0,0 +1,53 @@
"""Desktop application entry point."""
from __future__ import annotations
import os
import sys
from collections.abc import Sequence
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox
from javis.config.settings import ConfigurationError, Settings
from javis.core.provider_router import ApprovalChoice
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=lambda _decision: ApprovalChoice.CANCEL,
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()
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()
def main() -> int:
return run_gui(sys.argv)
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
"""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.provider_router import ApprovalChoice
from javis.core.runtime import JavisRuntime
from javis.memory.sqlite_store import ChatSession
from javis.providers.base import ProviderError, ResponseAbortedError
from javis.security.privacy import CloudPolicy, PrivacyDecision
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 approval_decision(self, text: str) -> PrivacyDecision | None:
provider = self.runtime.hybrid_provider
decision = provider.privacy_decision(text)
if provider.mode == "local" or decision.policy is not CloudPolicy.ASK:
return None
return decision
def queue_approval(self, choice: ApprovalChoice) -> None:
self.runtime.hybrid_provider.queue_approval(choice)
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 ResponseAbortedError:
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
except ProviderError as exc:
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
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
state = self.state
if state is GenerationState.READY and not runtime_status.ollama_reachable:
state = GenerationState.OFFLINE
return DesktopStatus(
state=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,
)
+473
View File
@@ -0,0 +1,473 @@
"""Main PySide6 window for the Javis desktop MVP."""
from __future__ import annotations
from html import escape
from PySide6.QtCore import QSignalBlocker, 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.core.provider_router import ApprovalChoice
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._generation_finalized = True
self._close_when_finished = False
self._messages: list[ChatMessage] = []
self._draft_user = ""
self._draft_assistant = ""
self._thinking_dots = 0
self._build_ui()
self._connect_signals()
self._apply_style()
self._open_initial_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.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 _open_initial_session(self) -> None:
try:
sessions = self.controller.list_sessions()
if sessions:
loaded = self.controller.load_session(sessions[0].id)
self._messages = list(loaded.messages)
self._render_chat()
self._refresh_sessions()
self._refresh_status()
return
except Exception as exc:
self._show_error(str(exc))
self._new_session()
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
try:
decision = self.controller.approval_decision(text)
if decision is not None:
choice = self._show_approval(decision)
if choice is ApprovalChoice.CANCEL:
self.statusBar().showMessage("Anfrage abgebrochen.", 3000)
return
self.controller.queue_approval(choice)
except Exception as exc:
self._show_error(str(exc))
return
self.message_edit.clear()
self._draft_user = text
self._draft_assistant = ""
self._render_chat()
self._set_generating(True)
self._generation_finalized = False
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(worker.deleteLater)
worker.completed.connect(self._generation_complete)
thread.finished.connect(self._thread_finished)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
def _handle_stream_event(self, event: StreamEvent) -> None:
if self._generation_finalized:
return
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:
if self._generation_finalized:
return
self._generation_finalized = True
failed = self.controller.state in {
GenerationState.ABORTED,
GenerationState.ERROR,
}
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 _thread_finished(self) -> None:
self._worker = None
self._thread = None
if self._close_when_finished:
self._close_when_finished = False
QTimer.singleShot(0, self.close)
def _stop_generation(self) -> None:
if self._worker is None:
return
self._worker.request_cancel()
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) -> ApprovalChoice:
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)
cancel = box.addButton("Abbrechen", QMessageBox.DestructiveRole)
box.exec()
clicked = box.clickedButton()
choice = ApprovalChoice.CANCEL
if clicked is allow:
choice = ApprovalChoice.ALLOW
elif clicked is local:
choice = ApprovalChoice.LOCAL
elif clicked is cancel:
choice = ApprovalChoice.CANCEL
if box.clickedButton() is local:
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
return choice
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)
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["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:
if self._thread is not None:
self._close_when_finished = True
self._stop_generation()
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
+76
View File
@@ -0,0 +1,76 @@
"""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.core.provider_router import ApprovalChoice
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 = ApprovalChoice.CANCEL
def request(self, decision: PrivacyDecision) -> ApprovalChoice:
event = threading.Event()
with self._lock:
self._event = event
self._result = ApprovalChoice.CANCEL
self.approval_requested.emit(decision)
event.wait()
with self._lock:
result = self._result
self._event = None
return result
def resolve(self, choice: ApprovalChoice) -> None:
with self._lock:
self._result = choice
event = self._event
if event is not None:
event.set()
def cancel_pending(self) -> None:
self.resolve(ApprovalChoice.CANCEL)
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()
self._started = False
self._completed = False
@Slot()
def run(self) -> None:
if self._started:
return
self._started = True
try:
for event in self._controller.stream_message(
self._text,
self._cancel_event,
):
self.event_received.emit(event)
finally:
if not self._completed:
self._completed = True
self.completed.emit()
def request_cancel(self) -> None:
self._cancel_event.set()
+10 -1
View File
@@ -7,7 +7,12 @@ from types import SimpleNamespace
from javis.config.settings import Settings
from javis.core.chat_service import ChatService
from javis.core.provider_router import HybridProvider
from javis.interface.cli import build_runtime, run_chat, run_gemini_configuration
from javis.interface.cli import (
build_parser,
build_runtime,
run_chat,
run_gemini_configuration,
)
from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy
@@ -62,6 +67,10 @@ class _LazySecrets:
class CliTests(unittest.TestCase):
def test_parser_recognizes_gui_and_existing_chat(self) -> None:
self.assertEqual(build_parser().parse_args(["gui"]).command, "gui")
self.assertEqual(build_parser().parse_args(["chat"]).command, "chat")
def test_basic_chat_commands(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
from javis.core.chat_service import ChatService
from javis.core.provider_router import ApprovalChoice
from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage, ResponseAbortedError
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.chat_controller import DesktopController, sanitized_error
from javis.ui.models import GenerationState, StreamEventKind
class _Service:
def __init__(self) -> None:
self.sessions = [
SimpleNamespace(id="one", title="Eins"),
SimpleNamespace(id="two", title="Zwei"),
]
def new_session(self):
return self.sessions[0]
def list_sessions(self):
return self.sessions
def search_sessions(self, text: str):
return [session for session in self.sessions if text in session.title]
def load_session(self, session_id: str):
session = next(item for item in self.sessions if item.id == session_id)
return SimpleNamespace(
session=session,
messages=[ChatMessage("user", "Hallo")],
)
def rename_session(self, session_id: str, title: str):
session = next(item for item in self.sessions if item.id == session_id)
session.title = title
return session
class _Hybrid:
mode = "auto"
last_route = None
queued_approval = None
def set_mode(self, mode: str) -> None:
self.mode = mode
def privacy_decision(self, _text: str) -> PrivacyDecision:
return PrivacyDecision(CloudPolicy.ASK, "Test")
def queue_approval(self, choice: ApprovalChoice) -> None:
self.queued_approval = choice
class _StreamingProvider:
name = "ollama"
model = "test"
last_route = None
mode = "local"
def stream_chat(self, _messages):
yield "Teil "
yield "Antwort"
def set_mode(self, mode: str) -> None:
self.mode = mode
class _AbortingProvider(_StreamingProvider):
def stream_chat(self, _messages):
raise ResponseAbortedError("Vom Dialog abgebrochen")
yield
class DesktopControllerTests(unittest.TestCase):
def setUp(self) -> None:
self.runtime = SimpleNamespace(
service=_Service(),
hybrid_provider=_Hybrid(),
)
self.controller = DesktopController(self.runtime)
def test_controller_manages_sessions_without_qt_window(self) -> None:
created = self.controller.new_session()
listed = self.controller.list_sessions()
searched = self.controller.list_sessions("Zwei")
loaded = self.controller.load_session("two")
renamed = self.controller.rename_active_session("Neu")
self.assertEqual(created.id, "one")
self.assertEqual(len(listed), 2)
self.assertEqual([session.id for session in searched], ["two"])
self.assertEqual(loaded.messages[0].content, "Hallo")
self.assertEqual(renamed.title, "Neu")
def test_controller_preflights_and_queues_gui_approval(self) -> None:
decision = self.controller.approval_decision("Private Frage")
self.controller.queue_approval(ApprovalChoice.ALLOW)
self.assertIsNotNone(decision)
self.assertIs(decision.policy, CloudPolicy.ASK)
self.assertIs(
self.runtime.hybrid_provider.queued_approval,
ApprovalChoice.ALLOW,
)
def test_error_sanitizer_masks_key_patterns(self) -> None:
secret = "AIza" + "x" * 25
result = sanitized_error(RuntimeError(f"Fehler {secret}"))
self.assertNotIn(secret, result)
self.assertIn("MASKIERT", result)
def test_streaming_events_are_forwarded_and_completed(self) -> None:
with tempfile.TemporaryDirectory() as directory:
provider = _StreamingProvider()
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(
[event.text for event in events if event.kind is StreamEventKind.CHUNK],
["Teil ", "Antwort"],
)
self.assertEqual(events[-1].kind, StreamEventKind.FINISHED)
session = service.list_sessions()[0]
self.assertEqual(service.load_session(session.id).session.message_count, 2)
def test_cancel_drops_partial_exchange(self) -> None:
with tempfile.TemporaryDirectory() as directory:
provider = _StreamingProvider()
service = ChatService(
SQLiteSessionStore(Path(directory) / "sessions.sqlite3"),
provider,
)
controller = DesktopController(
SimpleNamespace(service=service, hybrid_provider=provider)
)
cancel = threading.Event()
cancel.set()
events = list(controller.stream_message("Hallo", cancel))
self.assertEqual(events[-1].state, GenerationState.ABORTED)
session = service.list_sessions()[0]
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])
+282
View File
@@ -0,0 +1,282 @@
from __future__ import annotations
import os
import threading
import time
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, StreamEvent, StreamEventKind
from javis.ui.workers import ApprovalBridge, StreamWorker
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",
)
self.messages = [
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
]
self.approval: PrivacyDecision | None = None
self.queued_approval: ApprovalChoice | None = None
self.started = threading.Event()
self.release = threading.Event()
self.release.set()
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=list(self.messages),
)
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 approval_decision(self, _text: str) -> PrivacyDecision | None:
return self.approval
def queue_approval(self, choice: ApprovalChoice) -> None:
self.queued_approval = choice
def stream_message(self, text: str, cancel_event: threading.Event):
self.started.set()
self.state = GenerationState.THINKING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
while not self.release.wait(0.005):
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
self.state = GenerationState.ANSWERING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
yield StreamEvent(StreamEventKind.CHUNK, text="Testantwort")
self.messages.extend(
[
ChatMessage("user", text),
ChatMessage("assistant", "Testantwort"),
]
)
self.session.message_count = len(self.messages)
self.state = GenerationState.READY
yield StreamEvent(StreamEventKind.FINISHED, state=self.state)
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.controller = _Controller()
self.window = MainWindow(self.controller, self.bridge)
self.window.show()
self.application.processEvents()
def tearDown(self) -> None:
self.controller.release.set()
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.window.close()
self.application.processEvents()
def _process_until(self, condition, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while not condition() and time.monotonic() < deadline:
self.application.processEvents()
time.sleep(0.005)
self.application.processEvents()
self.assertTrue(condition())
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.assertIs(self.window._show_approval(decision), choice)
def test_local_completion_and_second_question_keep_gui_reusable(self) -> None:
for text in ("Erste Frage", "Zweite Frage"):
self.window.message_edit.setPlainText(text)
self.window._send()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.session.message_count, 6)
self.assertIn("Zweite Frage", self.window.chat_view.toPlainText())
self.assertTrue(self.window.send_button.isEnabled())
def test_approval_is_decided_before_worker_starts(self) -> None:
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
self.window._show_approval = Mock(return_value=ApprovalChoice.ALLOW)
self.window.message_edit.setPlainText("Private Testfrage")
self.window._send()
self.assertIs(self.controller.queued_approval, ApprovalChoice.ALLOW)
self.window._show_approval.assert_called_once()
self._process_until(lambda: self.window._thread is None)
def test_local_approval_choice_is_queued_before_worker_starts(self) -> None:
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
self.window._show_approval = Mock(return_value=ApprovalChoice.LOCAL)
self.window.message_edit.setPlainText("Lokal beantworten")
self.window._send()
self.assertIs(self.controller.queued_approval, ApprovalChoice.LOCAL)
self._process_until(lambda: self.window._thread is None)
def test_stop_during_streaming_discards_partial_exchange(self) -> None:
self.controller.release.clear()
before = list(self.controller.messages)
self.window.message_edit.setPlainText("Nicht speichern")
self.window._send()
self._process_until(self.controller.started.is_set)
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.messages, before)
self.assertEqual(self.controller.state, GenerationState.ABORTED)
def test_stop_immediately_after_start_is_safe(self) -> None:
self.controller.release.clear()
self.window.message_edit.setPlainText("Sofort stoppen")
self.window._send()
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.session.message_count, 2)
def test_completion_is_idempotent_and_retains_qt_references(self) -> None:
worker = object()
thread = object()
self.window._worker = worker
self.window._thread = thread
self.window._generation_finalized = False
self.window._generation_complete()
self.window._generation_complete()
self.assertIs(self.window._worker, worker)
self.assertIs(self.window._thread, thread)
self.window._thread_finished()
self.assertIsNone(self.window._worker)
self.assertIsNone(self.window._thread)
def test_worker_emits_completion_only_once(self) -> None:
worker = StreamWorker(self.controller, "Nur einmal")
completions: list[bool] = []
worker.completed.connect(lambda: completions.append(True))
worker.run()
worker.run()
self.assertEqual(completions, [True])
def test_close_during_generation_cancels_then_closes(self) -> None:
self.controller.release.clear()
self.window.message_edit.setPlainText("Beim Schließen stoppen")
self.window._send()
self._process_until(self.controller.started.is_set)
self.window.close()
self.assertTrue(self.window._close_when_finished)
self._process_until(lambda: self.window._thread is None)
self._process_until(lambda: not self.window.isVisible())
+26 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import unittest
from pathlib import Path
from javis.core.provider_router import HybridProvider
from javis.core.provider_router import ApprovalChoice, HybridProvider
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore
from javis.providers.base import (
ChatMessage,
@@ -182,6 +182,31 @@ class HybridProviderTests(unittest.TestCase):
self.assertEqual(answer, "Cloud")
self.assertEqual(len(self.cloud.calls), 1)
def test_queued_gui_approval_is_one_shot_and_uses_fake_cloud(self) -> None:
router = self._router()
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
router.queue_approval(ApprovalChoice.ALLOW)
chunks = list(router.stream_chat(question))
second_answer = router.chat(question)
self.assertEqual(chunks, ["Cloud"])
self.assertEqual(second_answer, "Lokal")
self.assertEqual(len(self.approvals), 1)
def test_ask_supports_allow_local_and_cancel_choices(self) -> None:
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
allowed = self._router(approval_callback=lambda _decision: ApprovalChoice.ALLOW)
self.assertEqual(allowed.chat(question), "Cloud")
local = self._router(approval_callback=lambda _decision: ApprovalChoice.LOCAL)
self.assertEqual(local.chat(question), "Lokal")
cancelled = self._router(approval_callback=lambda _decision: ApprovalChoice.CANCEL)
with self.assertRaises(ResponseAbortedError):
cancelled.chat(question)
def test_429_falls_back_without_losing_local_answer(self) -> None:
self.cloud.error = ProviderRateLimitError("quota")
router = self._router()
+18
View File
@@ -25,6 +25,24 @@ class StartScriptTests(unittest.TestCase):
):
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__":
unittest.main()
Generated
+128 -1
View File
@@ -218,6 +218,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
@@ -380,6 +389,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -423,10 +441,12 @@ source = { editable = "." }
dependencies = [
{ name = "google-genai" },
{ name = "keyring" },
{ name = "pyside6" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
@@ -434,10 +454,14 @@ dev = [
requires-dist = [
{ name = "google-genai", specifier = ">=2.6,<3" },
{ name = "keyring", specifier = ">=25.7,<26" },
{ name = "pyside6", specifier = ">=6.8,<7" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.12,<1" }]
dev = [
{ name = "pytest", specifier = ">=8,<9" },
{ name = "ruff", specifier = ">=0.12,<1" },
]
[[package]]
name = "jeepney"
@@ -475,6 +499,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
@@ -622,6 +664,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyside6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" },
{ url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" },
{ url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" },
{ url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" },
]
[[package]]
name = "pyside6-addons"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" },
{ url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" },
{ url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" },
{ url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
@@ -684,6 +799,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "shiboken6"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" },
{ url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" },
{ url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" },
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"