3 Commits

Author SHA1 Message Date
Dystroyer8 571969b69b test: verify and document desktop ui 2026-07-30 20:26:03 +02:00
Dystroyer8 cf35148576 feat: connect desktop sessions and streaming 2026-07-30 20:18:11 +02:00
Dystroyer8 1658e0485e feat: add desktop ui foundation 2026-07-30 20:15:20 +02:00
25 changed files with 1568 additions and 113 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:
+21 -21
View File
@@ -109,6 +109,14 @@ Rootserver:
- Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile. - Ollama, Gemini, Hybridrouter, Chatservice und CLI streamen sichtbare Antwortteile.
Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown Verdeckte Gedanken werden nicht angefordert oder ausgegeben; Terminal-Markdown
wird lesbar bereinigt. Strg+C verwirft Teilantworten atomar und kehrt zum Chat zurück. 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 ## Aktuelle Architektur
@@ -189,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: 91 bestanden; Navigation, Streaming, Migration, Datenschutz, - Tests: 101 bestanden; zusätzlich Qt-Offscreen-Fenster, Dialogzuordnung und
ID-Präfixe, begrenzte Suche und SQL-Sonderzeichen 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
@@ -235,29 +247,16 @@ Abnahmestatus:
## Git-Stand ## Git-Stand
- aktueller und geprüfter Branch: `main` - aktueller Arbeitsbranch: `feat/desktop-ui`
- stabiler Ausgangsstand: `main` bei `77b510b` - stabiler Ausgangsstand: `main` bei `d3e85f2`
- medizinischer Datenschutz-Fix: `9148193` - GUI-Grundgerüst und Kernadapter: `1658e04`
- Provider- und CLI-Streaming: `00a5a6c` - Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
- 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
- konfliktfreier Merge nach `main`: `564a3fd`
- 66 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert - `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
- 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`
- `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
Chatkomfort ist implementiert, gemergt, auf `main` erneut geprüft und gepusht. Auf `feat/desktop-ui` als Nächstes vollständige Abschlussprüfungen ausführen,
Jetzt stoppen und mit Pascal erst später ein einzelnes nächstes Paket festlegen. 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`
+2
View File
@@ -11,6 +11,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"google-genai>=2.6,<3", "google-genai>=2.6,<3",
"keyring>=25.7,<26", "keyring>=25.7,<26",
"pyside6>=6.8,<7",
] ]
[project.scripts] [project.scripts]
@@ -18,6 +19,7 @@ javis = "javis.interface.cli:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8,<9",
"ruff>=0.12,<1", "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 Push-Location $repositoryRoot
try { try {
if ($env:JAVIS_START_INTERFACE -eq 'gui') {
& $resolvedUv run javis gui
}
else {
& $resolvedUv run javis chat & $resolvedUv run javis chat
}
exit $LASTEXITCODE exit $LASTEXITCODE
} }
finally { finally {
+27 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum
from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError from javis.memory.usage_store import ProviderEvent, SQLiteUsageStore, UsageStoreError
from javis.providers.base import ( from javis.providers.base import (
@@ -18,7 +19,14 @@ from javis.providers.base import (
) )
from javis.security.privacy import CloudPolicy, PrivacyDecision, PrivacyRouter 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] NoticeCallback = Callable[[str], None]
CloudProviderFactory = Callable[[], LocalModelProvider] CloudProviderFactory = Callable[[], LocalModelProvider]
@@ -89,6 +97,14 @@ class HybridProvider:
raise ValueError("Provider-Modus muss auto, local oder gemini sein.") raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
self.mode = mode self.mode = mode
def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice:
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: def chat(self, messages: list[ChatMessage]) -> str:
current_text = messages[-1].content if messages else "" current_text = messages[-1].content if messages else ""
cloud_requested = self.mode == "gemini" cloud_requested = self.mode == "gemini"
@@ -110,8 +126,11 @@ class HybridProvider:
approved = decision.policy is CloudPolicy.ALLOWED approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK: if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision) choice = self._approval_choice(decision)
if not approved: 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) return self._local(messages, decision, "Cloudfreigabe abgelehnt", fallback=True)
unavailable_reason = self._cloud_unavailable_reason() unavailable_reason = self._cloud_unavailable_reason()
@@ -194,8 +213,11 @@ class HybridProvider:
approved = decision.policy is CloudPolicy.ALLOWED approved = decision.policy is CloudPolicy.ALLOWED
if decision.policy is CloudPolicy.ASK: if decision.policy is CloudPolicy.ASK:
approved = self._approval_callback(decision) choice = self._approval_choice(decision)
if not approved: 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( yield from self._stream_local(
messages, messages,
decision, 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.local_config import LocalConfigError, write_gemini_activation
from javis.config.settings import ConfigurationError, Settings from javis.config.settings import ConfigurationError, Settings
from javis.core.chat_service import ChatService, SessionProviderMismatchError 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 ( from javis.memory.sqlite_store import (
AmbiguousSessionReferenceError, AmbiguousSessionReferenceError,
ChatSession, ChatSession,
SessionNotFoundError, SessionNotFoundError,
SessionStoreError, SessionStoreError,
SQLiteSessionStore,
) )
from javis.memory.usage_store import SQLiteUsageStore, UsageStoreError from javis.memory.usage_store import UsageStoreError
from javis.providers.base import MissingApiKeyError, ProviderError from javis.providers.base import ProviderError
from javis.providers.gemini import GeminiProvider
from javis.providers.ollama import OllamaProvider
from javis.security.privacy import PrivacyDecision, PrivacyRouter
from javis.security.secrets import SecretProvider, SecretStoreError from javis.security.secrets import SecretProvider, SecretStoreError
InputFunction = Callable[[str], str] InputFunction = Callable[[str], str]
@@ -52,6 +48,7 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten") chat_parser = subparsers.add_parser("chat", help="lokalen Textchat starten")
chat_parser.add_argument("--session", help="vorhandene Sitzungs-ID laden") 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_parser = subparsers.add_parser(
"configure", "configure",
help="nicht geheime lokale Einstellungen einrichten", help="nicht geheime lokale Einstellungen einrichten",
@@ -385,79 +382,21 @@ def build_runtime(
output: OutputFunction, output: OutputFunction,
secret_provider: SecretProvider | None = None, secret_provider: SecretProvider | None = None,
) -> tuple[ChatService, StatusFunction]: ) -> tuple[ChatService, StatusFunction]:
store = SQLiteSessionStore(settings.database_path) def approve(_decision: object) -> bool:
usage_store = SQLiteUsageStore(settings.usage_database_path) answer = input_fn("Cloudfreigabe erforderlich. Einmalig erlauben? [j/N] ")
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] "
)
return _confirmed(answer) return _confirmed(answer)
def create_cloud_provider() -> GeminiProvider: runtime = build_javis_runtime(
try: settings,
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=approve, approval_callback=approve,
notice_callback=output, notice_callback=output,
mode=settings.provider_mode, secret_provider=secret_provider,
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,
) )
def status() -> list[str]: def status() -> list[str]:
ollama = local.probe() return runtime.status().cli_lines()
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 ChatService(store, hybrid), status return runtime.service, status
def main( def main(
@@ -484,6 +423,10 @@ def main(
input_fn=input_fn, input_fn=input_fn,
output=output, output=output,
) )
if args.command == "gui":
from javis.ui.app import run_gui
return run_gui([])
if args.command != "chat": if args.command != "chat":
return 2 return 2
+1
View File
@@ -0,0 +1 @@
"""PySide6 desktop interface for the existing Javis core."""
+52
View File
@@ -0,0 +1,52 @@
"""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.runtime import build_javis_runtime
from javis.memory.sqlite_store import SessionStoreError
from javis.memory.usage_store import UsageStoreError
from javis.ui.chat_controller import DesktopController
from javis.ui.main_window import MainWindow
from javis.ui.workers import ApprovalBridge
def run_gui(argv: Sequence[str] | None = None) -> int:
application = QApplication.instance() or QApplication(list(argv or []))
application.setApplicationName("Javis Desktop")
approval_bridge = ApprovalBridge()
try:
settings = Settings.from_env()
runtime = build_javis_runtime(
settings,
approval_callback=approval_bridge.request,
notice_callback=approval_bridge.notice_received.emit,
)
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
QMessageBox.critical(None, "Javis konnte nicht starten", str(exc))
return 2
window = MainWindow(DesktopController(runtime), approval_bridge)
window.show()
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())
+125
View File
@@ -0,0 +1,125 @@
"""Small non-Qt adapter between desktop widgets and the existing Javis core."""
from __future__ import annotations
import re
import threading
from collections.abc import Iterator
from javis.core.runtime import JavisRuntime
from javis.memory.sqlite_store import ChatSession
from javis.providers.base import ProviderError, ResponseAbortedError
from javis.ui.models import (
DesktopStatus,
GenerationState,
StreamEvent,
StreamEventKind,
)
_SECRET_PATTERN = re.compile(
r"AIza[\w-]{20,}|sk-[\w-]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----",
re.IGNORECASE,
)
def sanitized_error(error: BaseException) -> str:
text = str(error).strip() or "Unbekannter Fehler."
return _SECRET_PATTERN.sub("[SECRET MASKIERT]", text)
class DesktopController:
"""Expose core operations without placing business rules in widgets."""
def __init__(self, runtime: JavisRuntime) -> None:
self.runtime = runtime
self.service = runtime.service
self.active_session_id: str | None = None
self.state = GenerationState.READY
def ensure_session(self) -> ChatSession:
if self.active_session_id is None:
session = self.service.new_session()
self.active_session_id = session.id
return session
return self.service.load_session(self.active_session_id).session
def new_session(self) -> ChatSession:
session = self.service.new_session()
self.active_session_id = session.id
return session
def list_sessions(self, search: str = "") -> list[ChatSession]:
return (
self.service.search_sessions(search) if search.strip() else self.service.list_sessions()
)
def load_session(self, session_id: str):
loaded = self.service.load_session(session_id)
self.active_session_id = loaded.session.id
return loaded
def rename_active_session(self, title: str) -> ChatSession:
session = self.ensure_session()
return self.service.rename_session(session.id, title)
def set_mode(self, mode: str) -> None:
self.runtime.hybrid_provider.set_mode(mode)
def stream_message(
self,
text: str,
cancel_event: threading.Event,
) -> Iterator[StreamEvent]:
session = self.ensure_session()
stream = self.service.stream_send(session.id, text)
self.state = GenerationState.THINKING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
started = False
try:
for chunk in stream:
if cancel_event.is_set():
stream.close()
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
if not started:
started = True
self.state = GenerationState.ANSWERING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
yield StreamEvent(StreamEventKind.CHUNK, text=chunk)
except 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,
)
+449
View File
@@ -0,0 +1,449 @@
"""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._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.approval_requested.connect(self._show_approval)
self.approval_bridge.notice_received.connect(self._show_notice)
def _apply_style(self) -> None:
self.setStyleSheet(
"""
QMainWindow, QWidget { background: #171c21; color: #e8edf2; }
QLineEdit, QTextEdit, QTextBrowser, QListWidget, QComboBox {
background: #202b35; border: 1px solid #344553;
border-radius: 6px; padding: 7px; selection-background-color: #b97828;
}
QPushButton { background: #2b3945; border: 1px solid #485b69;
border-radius: 6px; padding: 8px 14px; }
QPushButton:hover { border-color: #d3943f; }
QPushButton:disabled { color: #68747d; }
#panelTitle { color: #d3943f; font-weight: 700; letter-spacing: 1px; }
#stateLabel { background: #24342e; color: #86d49b; padding: 10px;
border-radius: 6px; font-weight: 700; }
QStatusBar { background: #11161a; color: #b8c2ca; }
"""
)
def _new_session(self) -> None:
if self._worker is not None:
return
session = self.controller.new_session()
self._messages = []
self._draft_user = self._draft_assistant = ""
self._render_chat()
self._refresh_sessions()
self.statusBar().showMessage(f"Neue Sitzung: {session.title}", 3000)
self.message_edit.setFocus()
self._refresh_status()
def _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
self.message_edit.clear()
self._draft_user = text
self._draft_assistant = ""
self._render_chat()
self._set_generating(True)
thread = QThread(self)
worker = StreamWorker(self.controller, text)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.event_received.connect(self._handle_stream_event)
worker.completed.connect(thread.quit)
worker.completed.connect(self._generation_complete)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
def _handle_stream_event(self, event: StreamEvent) -> None:
if event.kind is StreamEventKind.CHUNK:
self._draft_assistant += event.text
self._render_chat()
if event.state is not None:
self._set_state(event.state)
if event.kind is StreamEventKind.ERROR:
self._show_error(event.text)
def _generation_complete(self) -> None:
failed = self.controller.state in {
GenerationState.ABORTED,
GenerationState.ERROR,
}
self._worker = None
self._thread = None
self._set_generating(False)
try:
loaded = self.controller.load_session(self.controller.active_session_id or "")
self._messages = list(loaded.messages)
except Exception as exc:
self._show_error(str(exc))
self._draft_user = self._draft_assistant = ""
self._render_chat()
self._refresh_sessions()
self._refresh_status()
if failed:
self.statusBar().showMessage("Antwort abgebrochen oder fehlgeschlagen.", 5000)
else:
self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000)
def _stop_generation(self) -> None:
if self._worker is None:
return
self._worker.request_cancel()
self.approval_bridge.cancel_pending()
self.statusBar().showMessage("Abbruch angefordert …")
def _set_generating(self, active: bool) -> None:
self.send_button.setEnabled(not active)
self.stop_button.setEnabled(active)
self.new_button.setEnabled(not active)
self.session_list.setEnabled(not active)
self.mode_combo.setEnabled(not active)
def _mode_changed(self) -> None:
try:
self.controller.set_mode(self.mode_combo.currentData())
self._refresh_status()
except Exception as exc:
self._show_error(str(exc))
def _show_approval(self, decision: PrivacyDecision) -> None:
box = QMessageBox(self)
box.setWindowTitle("Cloudfreigabe")
box.setIcon(QMessageBox.Question)
box.setText(
"Diese Anfrage darf nur nach deiner Freigabe an Gemini gesendet werden.\n\n"
"Es wird ausschließlich der minimierte notwendige Kontext übertragen."
)
box.setInformativeText(f"Lokale Einstufung: {decision.reason}")
allow = box.addButton("Einmal erlauben", QMessageBox.AcceptRole)
local = box.addButton("Lokal beantworten", QMessageBox.RejectRole)
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
self.approval_bridge.resolve(choice)
if box.clickedButton() is local:
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
def _show_notice(self, text: str) -> None:
self.statusBar().showMessage(text, 5000)
def _show_error(self, text: str) -> None:
QMessageBox.critical(self, "Javis-Fehler", text)
self.statusBar().showMessage(f"Fehler: {text}", 5000)
def _refresh_status(self) -> None:
try:
status = self.controller.status()
except Exception as exc:
self._show_error(str(exc))
return
self._set_state(status.state)
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:
self._stop_generation()
if self._thread is not None and not self._thread.wait(2500):
event.ignore()
self.statusBar().showMessage(
"Javis beendet die laufende Antwort noch kontrolliert.",
3000,
)
return
event.accept()
+43
View File
@@ -0,0 +1,43 @@
"""UI-facing immutable models without Qt dependencies."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class GenerationState(StrEnum):
OFFLINE = "Offline"
READY = "Bereit"
THINKING = "Denkt"
ANSWERING = "Antwortet"
ABORTED = "Abgebrochen"
ERROR = "Fehler"
class StreamEventKind(StrEnum):
STATE = "state"
CHUNK = "chunk"
NOTICE = "notice"
ERROR = "error"
FINISHED = "finished"
@dataclass(frozen=True, slots=True)
class StreamEvent:
kind: StreamEventKind
text: str = ""
state: GenerationState | None = None
@dataclass(frozen=True, slots=True)
class DesktopStatus:
state: GenerationState
mode: str
provider: str
ollama_reachable: bool
local_model: str
privacy_policy: str
cloud_requests_today: int | None
local_fallback: bool
data_dir: str
+69
View File
@@ -0,0 +1,69 @@
"""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()
@Slot()
def run(self) -> None:
try:
for event in self._controller.stream_message(
self._text,
self._cancel_event,
):
self.event_received.emit(event)
finally:
self.completed.emit()
def request_cancel(self) -> None:
self._cancel_event.set()
+10 -1
View File
@@ -7,7 +7,12 @@ from types import SimpleNamespace
from javis.config.settings import Settings from javis.config.settings import Settings
from javis.core.chat_service import ChatService from javis.core.chat_service import ChatService
from javis.core.provider_router import HybridProvider 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.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy from javis.security.privacy import CloudPolicy
@@ -62,6 +67,10 @@ class _LazySecrets:
class CliTests(unittest.TestCase): 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: def test_basic_chat_commands(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3") store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
+155
View File
@@ -0,0 +1,155 @@
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.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage, ResponseAbortedError
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
def set_mode(self, mode: str) -> None:
self.mode = mode
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_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])
+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)
+14 -1
View File
@@ -4,7 +4,7 @@ import tempfile
import unittest import unittest
from pathlib import Path 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.memory.usage_store import ProviderEvent, SQLiteUsageStore
from javis.providers.base import ( from javis.providers.base import (
ChatMessage, ChatMessage,
@@ -182,6 +182,19 @@ class HybridProviderTests(unittest.TestCase):
self.assertEqual(answer, "Cloud") self.assertEqual(answer, "Cloud")
self.assertEqual(len(self.cloud.calls), 1) self.assertEqual(len(self.cloud.calls), 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: def test_429_falls_back_without_losing_local_answer(self) -> None:
self.cloud.error = ProviderRateLimitError("quota") self.cloud.error = ProviderRateLimitError("quota")
router = self._router() router = self._router()
+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()
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" }, { 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]] [[package]]
name = "cryptography" name = "cryptography"
version = "49.0.0" 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" }, { 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]] [[package]]
name = "jaraco-classes" name = "jaraco-classes"
version = "3.4.0" version = "3.4.0"
@@ -423,10 +441,12 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "google-genai" }, { name = "google-genai" },
{ name = "keyring" }, { name = "keyring" },
{ name = "pyside6" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -434,10 +454,14 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "google-genai", specifier = ">=2.6,<3" }, { name = "google-genai", specifier = ">=2.6,<3" },
{ name = "keyring", specifier = ">=25.7,<26" }, { name = "keyring", specifier = ">=25.7,<26" },
{ name = "pyside6", specifier = ">=6.8,<7" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.12,<1" }] dev = [
{ name = "pytest", specifier = ">=8,<9" },
{ name = "ruff", specifier = ">=0.12,<1" },
]
[[package]] [[package]]
name = "jeepney" 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" }, { 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]] [[package]]
name = "pyasn1" name = "pyasn1"
version = "0.6.4" 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" }, { 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]] [[package]]
name = "pywin32-ctypes" name = "pywin32-ctypes"
version = "0.2.3" 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" }, { 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]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"