Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 571969b69b | |||
| cf35148576 | |||
| 1658e0485e | |||
| d3e85f2077 | |||
| 3361eae564 | |||
| cbba1024d7 | |||
| 328dcd9f74 | |||
| 43c1f76d84 | |||
| 112ae6d1da | |||
| 00a5a6ce8c | |||
| 9148193806 | |||
| 77b510bd22 | |||
| b52757702d | |||
| 564a3fdbd3 |
@@ -24,6 +24,14 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
|
|||||||
- Strikter Nullkostenmodus, lokale Schutzlimits und inhaltsfreies Provider-Audit.
|
- Strikter Nullkostenmodus, lokale Schutzlimits und inhaltsfreies Provider-Audit.
|
||||||
- Bereinigte CLI-Befehle `/provider`, `/privacy` und `/status`.
|
- Bereinigte CLI-Befehle `/provider`, `/privacy` und `/status`.
|
||||||
- Sicheres PowerShell-Startskript ohne Systemänderungen.
|
- Sicheres PowerShell-Startskript ohne Systemänderungen.
|
||||||
|
- Gemeinsames Streaming für Ollama und Gemini mit atomarer Speicherung,
|
||||||
|
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
|
### Changed
|
||||||
|
|
||||||
@@ -42,6 +50,8 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
|
|||||||
- Allgemeine unpersönliche Technikfragen werden präziser als `allowed`
|
- Allgemeine unpersönliche Technikfragen werden präziser als `allowed`
|
||||||
klassifiziert; persönliche, interne und geheime Inhalte behalten Vorrang.
|
klassifiziert; persönliche, interne und geheime Inhalte behalten Vorrang.
|
||||||
- Privacy-Ausgabe unterscheidet lokalen Modus von technischem Fallback.
|
- Privacy-Ausgabe unterscheidet lokalen Modus von technischem Fallback.
|
||||||
|
- SQLite-Sitzungen werden verlustfrei und idempotent um Titel und zuletzt
|
||||||
|
verwendeten Provider migriert.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -51,5 +61,7 @@ Alle wesentlichen Projektänderungen werden hier in verständlicher Form dokumen
|
|||||||
Nicht-Billing deaktiviert; Javis aktiviert niemals Billing.
|
Nicht-Billing deaktiviert; Javis aktiviert niemals Billing.
|
||||||
- Cloudkontext wird lokal klassifiziert und minimiert; 429, Netzwerk-, Timeout-,
|
- Cloudkontext wird lokal klassifiziert und minimiert; 429, Netzwerk-, Timeout-,
|
||||||
Key- und Modellfehler fallen auf Ollama zurück.
|
Key- und Modellfehler fallen auf Ollama zurück.
|
||||||
|
- Gesundheits-, Symptom-, Verletzungs-, Medikamenten-, Diagnose-, Behandlungs-
|
||||||
|
und medizinische Notfallfragen werden vor Cloudclient-Erstellung strikt lokal behandelt.
|
||||||
- Gemini-/Datenschutzablauf wurde am 30.07.2026 zusätzlich von Pascal manuell
|
- Gemini-/Datenschutzablauf wurde am 30.07.2026 zusätzlich von Pascal manuell
|
||||||
erfolgreich abgenommen; kein Keywert erschien in der Ausgabe.
|
erfolgreich abgenommen; kein Keywert erschien in der Ausgabe.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
+49
-42
@@ -58,8 +58,8 @@ Rootserver:
|
|||||||
- Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`.
|
- Modelle liegen außerhalb von Git unter `D:\Javis-Data\ollama-models`.
|
||||||
- Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry,
|
- Der lokale Ollama-Dienst bindet nur an `127.0.0.1:11434`; PATH, Registry,
|
||||||
Autostart und Windows-Dienste blieben unverändert.
|
Autostart und Windows-Dienste blieben unverändert.
|
||||||
- CLI-Befehle: `/new`, `/sessions`, `/load <ID>`, `/clear`, `/provider`,
|
- CLI-Befehle: `/new`, `/sessions`, `/load <NR|ID>`, `/search <Text>`,
|
||||||
`/privacy`, `/status`, `/help`, `/exit`.
|
`/rename <Titel>`, `/clear`, `/provider`, `/privacy`, `/status`, `/help`, `/exit`.
|
||||||
- Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte
|
- Sitzungsdaten liegen über `JAVIS_DATA_DIR` außerhalb von Git; der bestätigte
|
||||||
Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`.
|
Smoke-Test nutzte `D:\Javis-Data\runtime\core-chat-smoke`.
|
||||||
- Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert.
|
- Kein Obsidian-, Laptop- oder Rootserverzugriff wurde implementiert.
|
||||||
@@ -102,8 +102,21 @@ Rootserver:
|
|||||||
- Allgemeine unpersönliche Technikfragen zu RAM/SSD, SQLite, HTTP, Git und
|
- Allgemeine unpersönliche Technikfragen zu RAM/SSD, SQLite, HTTP, Git und
|
||||||
ähnlichen Begriffen werden gezielter `allowed`; `never` und `ask` behalten
|
ähnlichen Begriffen werden gezielter `allowed`; `never` und `ask` behalten
|
||||||
Vorrang bei Secrets, interner Infrastruktur und persönlichem Kontext.
|
Vorrang bei Secrets, interner Infrastruktur und persönlichem Kontext.
|
||||||
|
- Gesundheits-, Symptom-, Blutungs-, Verletzungs-, Medikamenten-, Diagnose-,
|
||||||
|
Behandlungs- und medizinische Notfallfragen sind zwingend lokal `never`.
|
||||||
- `/privacy` trennt Inhaltsklassifikation, tatsächlichen Provider, Unterdrückung
|
- `/privacy` trennt Inhaltsklassifikation, tatsächlichen Provider, Unterdrückung
|
||||||
durch Modus `local` und technischen Provider-Fallback.
|
durch Modus `local` und technischen Provider-Fallback.
|
||||||
|
- 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
|
## Aktuelle Architektur
|
||||||
|
|
||||||
@@ -111,7 +124,10 @@ Rootserver:
|
|||||||
- plattformunabhängige Pfade und zentrale Konfiguration
|
- plattformunabhängige Pfade und zentrale Konfiguration
|
||||||
- Modell-Provider als kleine austauschbare Schnittstelle
|
- Modell-Provider als kleine austauschbare Schnittstelle
|
||||||
- Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen
|
- Ollama-Provider akzeptiert nur lokale HTTP-Loopback-Adressen
|
||||||
- SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren
|
- SQLite-Sitzungsspeicher mit atomaren Benutzer-/Assistentenpaaren, neutralem
|
||||||
|
Starttitel, lokaler sicherer Titelerzeugung und zuletzt genutztem Provider
|
||||||
|
- Streaming wird erst nach vollständigem Abschluss atomar gespeichert; bei Abbruch
|
||||||
|
bleibt weder die Benutzerfrage noch eine unvollständige Antwort im Verlauf.
|
||||||
- Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard
|
- Laufzeitdaten außerhalb von Git über `JAVIS_DATA_DIR` oder sicheren Plattformstandard
|
||||||
- Hybridprovider bevorzugt Gemini für cloudgeeignete Inhalte und verwendet
|
- Hybridprovider bevorzugt Gemini für cloudgeeignete Inhalte und verwendet
|
||||||
Ollama bei Datenschutz, Ablehnung, Offline-, Quota- und Providerfehlern
|
Ollama bei Datenschutz, Ablehnung, Offline-, Quota- und Providerfehlern
|
||||||
@@ -162,6 +178,11 @@ Rootserver:
|
|||||||
- Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste.
|
- Lesender Ollama-Status prüft nur Loopback-Erreichbarkeit und Modellliste.
|
||||||
- Sicheres PowerShell-Startskript und kompakte Startdokumentation ergänzt; keine
|
- Sicheres PowerShell-Startskript und kompakte Startdokumentation ergänzt; keine
|
||||||
PATH-, Registry-, Autostart-, Dienst- oder Richtlinienänderung.
|
PATH-, Registry-, Autostart-, Dienst- oder Richtlinienänderung.
|
||||||
|
- Medizinische Inhalte vor Cloudclient-Erstellung strikt auf `never` gesetzt;
|
||||||
|
bestehende SQLite-Daten transaktional und wiederholbar um Titel/Provider migriert.
|
||||||
|
- Echtes Ollama-/Gemini-Streaming, providerübergreifendes Fallback vor der ersten
|
||||||
|
Ausgabe und sicheren Streaming-Abbruch nach Teilausgabe implementiert.
|
||||||
|
- CLI-Streaming mit lesbarer Markdown-Bereinigung und sicherem Strg+C ergänzt.
|
||||||
|
|
||||||
## Aktuelle Tests
|
## Aktuelle Tests
|
||||||
|
|
||||||
@@ -176,11 +197,19 @@ 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: 66 bestanden; neue Technikklassifikation und Privacy-Ausgabe 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
|
- PowerShell-Syntax des Startskripts: erfolgreich geparst
|
||||||
- Ruff Lint: bestanden
|
- Ruff Lint: bestanden
|
||||||
- Ruff Formatprüfung: bestanden
|
- Ruff Formatprüfung: bestanden
|
||||||
- lokaler CLI-Smoke ohne Secretzugriff: bestanden
|
- lokaler CLI-Smoke ohne Secretzugriff: bestanden
|
||||||
|
- echter Ollama-CLI-Streaming-Smoke mit Neustart/Laden: bestanden
|
||||||
|
- echter Gemini-Streaming-Smoke mit öffentlichem Inhalt: bestanden; ein Abschnitt,
|
||||||
|
20 sichtbare Zeichen, 14 Eingabe- und 4 Ausgabetoken; kein Keywert ausgegeben
|
||||||
- Obsidian-Vault unverändert: 1 Markdown-, 11 Gesamtdateien, letzter
|
- Obsidian-Vault unverändert: 1 Markdown-, 11 Gesamtdateien, letzter
|
||||||
Schreibzeitpunkt weiterhin 30.07.2026 15:42:31
|
Schreibzeitpunkt weiterhin 30.07.2026 15:42:31
|
||||||
- Rootserver und Ubuntu-Laptop wurden nicht kontaktiert
|
- Rootserver und Ubuntu-Laptop wurden nicht kontaktiert
|
||||||
@@ -218,48 +247,24 @@ Abnahmestatus:
|
|||||||
|
|
||||||
## Git-Stand
|
## Git-Stand
|
||||||
|
|
||||||
- aktueller Branch: `feat/gemini-privacy-router`
|
- aktueller Arbeitsbranch: `feat/desktop-ui`
|
||||||
- stabiler Branch: `main`
|
- stabiler Ausgangsstand: `main` bei `d3e85f2`
|
||||||
- Feature-Branch ist zu `origin/feat/gemini-privacy-router` gepusht und verfolgt
|
- GUI-Grundgerüst und Kernadapter: `1658e04`
|
||||||
diesen Remote-Branch; `main` und `origin/main` blieben auf `8a678ae`
|
- Sitzungen, Streaming, Abbruch und Datenschutz: `cf35148`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- Sitzungsnavigation: `43c1f76`
|
||||||
- letzter Commit vor der Handoff-Verdichtung: `0e82748`
|
- Dokumentation: `328dcd9`
|
||||||
- Handoff-Verdichtung auf `main`: `e6eb995`
|
- Chatkomfort-Merge nach `main`: `cbba102`
|
||||||
- Python-Entwicklungsumgebung auf dem Feature-Branch: `fa90a5c`
|
- 91 Unit-Tests, Ruff und Strukturprüfung auf dem gemergten `main` bestanden
|
||||||
- Laufzeit-/Modelldokumentation: `10d7cd7`
|
- Feature-Branch erfolgreich zu `origin/feat/chat-comfort` gepusht
|
||||||
- Chatkern und CLI: `ec9b2f9`
|
- `main` erfolgreich bis `3361eae` zu `origin/main` gepusht und synchron verifiziert
|
||||||
- Unit-Tests: `b153daa`
|
|
||||||
- Feature-Dokumentation: `5d567e5`
|
|
||||||
- lokaler Merge nach `main`: `f3d6dfb`
|
|
||||||
- geprüfter und gepushter Abschlussstand vor diesem Handoff-Update: `0a550e6`
|
|
||||||
- Secret-Provider: `570e7fa`
|
|
||||||
- Datenschutz/Nullkosten: `286d96d`
|
|
||||||
- isolierter Gemini-Provider: `f3e1183`
|
|
||||||
- Hybridrouter: `e57186f`
|
|
||||||
- lokale Konfiguration: `ab6ea48`
|
|
||||||
- CLI-Verdrahtung: `af9426e`
|
|
||||||
- Startskript: `141ff2e`
|
|
||||||
- Resttests: `a0af15a`
|
|
||||||
- `origin` verwendet HTTPS
|
- `origin` verwendet HTTPS
|
||||||
- kein Force-Push und keine umgeschriebene Historie
|
- kein Force-Push und keine umgeschriebene Historie
|
||||||
|
|
||||||
## Kompakter Changelog
|
|
||||||
|
|
||||||
- `49a1dd3`: erster Gitea-Verbindungstest
|
|
||||||
- `6d04171`: sicheres Javis-Grundgerüst
|
|
||||||
- `0e82748`: Werkzeug-, Token- und Handoff-Regeln
|
|
||||||
- `e6eb995`: einmalige Archivierung und Verdichtung des Handoffs
|
|
||||||
- aktuell: isolierte Python-Toolchain und reproduzierbare Entwicklungsumgebung
|
|
||||||
- aktuell: portable Ollama-Laufzeit und `qwen3:8b` außerhalb von Git verifiziert
|
|
||||||
- aktuell: lokaler CLI-Chat und persistente SQLite-Sitzungen vollständig abgenommen
|
|
||||||
- aktuell: Gemini-Free-Hybridrouting, Datenschutz und Nullkostenmodus
|
|
||||||
automatisiert und durch Pascal manuell vollständig abgenommen
|
|
||||||
|
|
||||||
## Offene Entscheidungen und Fehler
|
## Offene Entscheidungen und Fehler
|
||||||
|
|
||||||
- endgültiger Produkt-/Repositoryname bleibt offen
|
- endgültiger Produkt-/Repositoryname bleibt offen
|
||||||
- Startskript startet Ollama bei Bedarf pro Prozess; kein Autostart oder Dienst
|
- Startskript startet Ollama bei Bedarf pro Prozess; kein Autostart oder Dienst
|
||||||
- Antworten werden noch nicht gestreamt; Sitzungen besitzen noch keine Titel oder Suche
|
- Antworten werden gestreamt; Titel, Navigation und Suche sind implementiert
|
||||||
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben
|
- genauer späterer Obsidian-Schreibbereich ist nicht freigegeben
|
||||||
- normaler Secret-Provider ist festgelegt: Betriebssystem-Keyring; eine
|
- normaler Secret-Provider ist festgelegt: Betriebssystem-Keyring; eine
|
||||||
Klartext-XML wird nicht für API-Schlüssel verwendet
|
Klartext-XML wird nicht für API-Schlüssel verwendet
|
||||||
@@ -283,9 +288,11 @@ Abnahmestatus:
|
|||||||
|
|
||||||
## Nächster sinnvoller Auftrag
|
## Nächster sinnvoller Auftrag
|
||||||
|
|
||||||
Nach dem Merge ein separates, kleines Paket für Chatkomfort und Robustheit
|
Auf `feat/desktop-ui` als Nächstes vollständige Abschlussprüfungen ausführen,
|
||||||
planen, beispielsweise Sitzungstitel/Suche oder Streaming. Noch keine
|
Dokumentation committen und Feature-Branch pushen. Danach für Pascals manuelle
|
||||||
Obsidian-Integration, Tools, Sprache oder Serversteuerung beginnen.
|
Desktop-Abnahme stoppen; noch nicht nach `main` mergen.
|
||||||
|
Medizinische Antwortqualität bleibt ein späteres Sicherheits-/Systemprompt-Thema.
|
||||||
|
Noch keine Obsidian-Integration oder Tools beginnen.
|
||||||
|
|
||||||
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
|
Keinen API-Key in Chat, Git, Obsidian, Logs oder Dokumentation übernehmen.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
+12
-12
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
## Ausgangsstand
|
## Ausgangsstand
|
||||||
|
|
||||||
Der Core-Chat und der Gemini-/Datenschutz-Meilenstein sind automatisiert durch
|
Der Core-Chat, Gemini-/Datenschutz-Meilenstein und das Chatkomfort-Paket sind
|
||||||
Codex und am 30.07.2026 manuell durch Pascal abgenommen. Der Featurestand ist zum
|
automatisiert geprüft. Pascal hat Core-Chat sowie Gemini/Datenschutz am
|
||||||
Merge freigegeben.
|
30.07.2026 manuell abgenommen.
|
||||||
|
|
||||||
Bestätigt sind echter Gemini-Erfolg, verdeckte Key-Speicherung, Nullkostenmodus,
|
Chatkomfort umfasst sicheres Ollama-/Gemini-Streaming, Strg+C ohne Teilpersistenz,
|
||||||
bereinigter Status, Cloudzähler, `ask`- und `never`-Routing, erzwungener lokaler
|
medizinisches `never`, automatische und manuelle Sitzungstitel, nummerierte
|
||||||
Modus sowie das Startskript.
|
Listen, Laden per Nummer/ID-Präfix und begrenzte Suche in Titeln/Benutzertexten.
|
||||||
|
|
||||||
## Nächstes sinnvolles Paket
|
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.
|
||||||
|
|
||||||
Nur ein kleines Chatkomfort-/Robustheitsthema auswählen, zum Beispiel:
|
## Nächster sinnvoller Schritt
|
||||||
|
|
||||||
- automatische, lokal erzeugte Sitzungstitel und Sitzungssuche oder
|
Nur den Desktop-MVP nach `docs/DESKTOP_UI.md` manuell abnehmen. Erst danach mit
|
||||||
- Streaming lokaler und Cloudantworten mit sauberem Abbruchverhalten
|
Pascal ein einzelnes nächstes Paket festlegen; keine Folgephase automatisch beginnen.
|
||||||
|
|
||||||
Vorher wie immer Branch, Arbeitsbaum, aktuellen Handoff und Tests prüfen.
|
|
||||||
|
|
||||||
## Weiterhin nicht beginnen
|
## Weiterhin nicht beginnen
|
||||||
|
|
||||||
|
|||||||
+54
-18
@@ -1,6 +1,6 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
Stand: Gemini-Free-Feature automatisiert geprüft, 30.07.2026.
|
Stand: Desktop-MVP automatisiert und mit lokalem Modell geprüft, 30.07.2026.
|
||||||
|
|
||||||
## Erreicht
|
## Erreicht
|
||||||
|
|
||||||
@@ -20,10 +20,15 @@ Stand: Gemini-Free-Feature automatisiert geprüft, 30.07.2026.
|
|||||||
- zwei echte deutsche Modellantworten erfolgreich vollständig auf der RTX 3060 erzeugt
|
- zwei echte deutsche Modellantworten erfolgreich vollständig auf der RTX 3060 erzeugt
|
||||||
- kalter Test 36,19 s, warmer Kurztest 0,56 s; geladene GPU-Belegung 6.842 MiB
|
- kalter Test 36,19 s, warmer Kurztest 0,56 s; geladene GPU-Belegung 6.842 MiB
|
||||||
- lokaler Ollama-Provider, zentrale Konfiguration und SQLite-Sitzungsspeicher implementiert
|
- lokaler Ollama-Provider, zentrale Konfiguration und SQLite-Sitzungsspeicher implementiert
|
||||||
- CLI mit `/new`, `/sessions`, `/load`, `/clear`, `/help` und `/exit` implementiert
|
- CLI streamt Antworten und unterstützt `/new`, nummerierte `/sessions`,
|
||||||
|
`/load <NR|ID>`, `/search`, `/rename`, `/clear`, `/help` und `/exit`
|
||||||
|
- bestehende SQLite-Datenbanken werden automatisch, transaktional und
|
||||||
|
wiederholbar um Sitzungstitel und zuletzt verwendeten Provider migriert
|
||||||
|
- lokale Titelbildung ohne Modellaufruf; offensichtliche Secrets und medizinische
|
||||||
|
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
|
||||||
- 13 Unit-Tests, Ruff und Strukturprü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
|
||||||
@@ -38,7 +43,23 @@ Stand: Gemini-Free-Feature automatisiert geprüft, 30.07.2026.
|
|||||||
- für produktive Nutzung freigegeben: nicht separat erklärt; keine spätere Tool-,
|
- für produktive Nutzung freigegeben: nicht separat erklärt; keine spätere Tool-,
|
||||||
Obsidian- oder Autonomiefunktion
|
Obsidian- oder Autonomiefunktion
|
||||||
|
|
||||||
## Aktuelles Featurepaket
|
## Chatkomfort-Meilenstein
|
||||||
|
|
||||||
|
- Branch `feat/chat-comfort`
|
||||||
|
- Ollama und Gemini verwenden denselben optionalen Streamingvertrag; Provider
|
||||||
|
ohne Streaming bleiben kompatibel
|
||||||
|
- keine versteckten Gedanken; Terminal-Markdown wird lesbar bereinigt
|
||||||
|
- vollständige Antwort wird genau einmal gespeichert; Strg+C und Streamfehler
|
||||||
|
hinterlassen keine unvollständige Nachricht
|
||||||
|
- medizinische Fragen werden vor jeder Cloudclient-Erstellung zwingend `never`
|
||||||
|
- Sitzungsliste zeigt Nummer, kurze ID, Titel, Datum, Nachrichtenanzahl und Provider
|
||||||
|
- Laden per letzter Listennummer oder eindeutigem ID-Präfix; Mehrdeutigkeit wird erklärt
|
||||||
|
- Suche ist auf 20 Ergebnisse begrenzt, parametrisiert und durchsucht nur Titel
|
||||||
|
sowie Benutzernachrichten
|
||||||
|
- echter Ollama-CLI-Streaming-/Neustart-Smoke und echter kleiner
|
||||||
|
Gemini-Streaming-Smoke mit öffentlichem Inhalt erfolgreich
|
||||||
|
|
||||||
|
## Gemini-/Datenschutz-Meilenstein
|
||||||
|
|
||||||
- Branch `feat/gemini-privacy-router`
|
- Branch `feat/gemini-privacy-router`
|
||||||
- offizielle Gemini-Rahmenbedingungen geprüft; Standardmodell `gemini-3.6-flash`
|
- offizielle Gemini-Rahmenbedingungen geprüft; Standardmodell `gemini-3.6-flash`
|
||||||
@@ -67,7 +88,7 @@ Stand: Gemini-Free-Feature automatisiert geprüft, 30.07.2026.
|
|||||||
- `/privacy` trennt lokalen Modus von einem technischen Provider-Fallback
|
- `/privacy` trennt lokalen Modus von einem technischen Provider-Fallback
|
||||||
- sicheres `scripts/start-javis.ps1` mit relativer Repositoryprüfung,
|
- sicheres `scripts/start-javis.ps1` mit relativer Repositoryprüfung,
|
||||||
Loopback-Ollama und überschreibbaren externen Pfaden
|
Loopback-Ollama und überschreibbaren externen Pfaden
|
||||||
- 66 Unit-Tests aktuell erfolgreich; PowerShell-Syntax, Lockfile, Sync,
|
- 91 Unit-Tests aktuell erfolgreich; PowerShell-Syntax, Lockfile, Sync,
|
||||||
Strukturprüfung, Secret-Scan, Gitignore, Ruff-Format und Lint sauber
|
Strukturprüfung, Secret-Scan, Gitignore, Ruff-Format und Lint sauber
|
||||||
|
|
||||||
## Nicht implementiert
|
## Nicht implementiert
|
||||||
@@ -75,24 +96,39 @@ Stand: Gemini-Free-Feature 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
|
||||||
|
|
||||||
- stabiler Branch: `main`
|
- aktueller Branch: `feat/desktop-ui`
|
||||||
- aktueller Branch: `feat/gemini-privacy-router`
|
- Desktop-Basis: `main` bei `d3e85f2`
|
||||||
- Feature-Branch: zu `origin/feat/gemini-privacy-router` gepusht
|
- GUI-Grundgerüst: `1658e04`
|
||||||
- Grundgerüstcommit: `6d04171`
|
- Sitzungen/Streaming/Datenschutz: `cf35148`
|
||||||
- Basis des Feature-Branches: `e6eb995`
|
- Basis: `main` bei `77b510b`
|
||||||
- Toolchain: `fa90a5c`
|
- medizinisches Routing: `9148193`
|
||||||
- Laufzeit/Modell: `10d7cd7`
|
- Streaming: `00a5a6c`
|
||||||
- Chatkern: `ec9b2f9`
|
- Titelmigration: `112ae6d`
|
||||||
- Tests: `b153daa`
|
- Sitzungsnavigation: `43c1f76`
|
||||||
- Dokumentation: `5d567e5`
|
- Dokumentation: `328dcd9`
|
||||||
- Merge nach `main`: `f3d6dfb`
|
- 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
|
## Bekannte Einschränkung
|
||||||
|
|
||||||
Das Startskript startet Ollama bei Bedarf lokal. Antworten werden noch nicht
|
Das Startskript startet Ollama bei Bedarf lokal. Eine Löschfunktion ist nicht
|
||||||
gestreamt; Sitzungen haben noch keine Titel, Suche oder Löschfunktion.
|
Bestandteil dieses Pakets.
|
||||||
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
|
`qwen3:8b` ist nicht automatisch für die GTX 1050 des späteren Zielhosts geeignet.
|
||||||
Der Gemini-Key liegt ausschließlich im Betriebssystem-Keyring; sein Wert wurde
|
Der Gemini-Key liegt ausschließlich im Betriebssystem-Keyring; sein Wert wurde
|
||||||
von Codex weder gelesen noch ausgegeben. Pascal hat den echten Cloud- und
|
von Codex weder gelesen noch ausgegeben. Pascal hat den echten Cloud- und
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -113,7 +113,12 @@ if (-not (Test-OllamaReady)) {
|
|||||||
|
|
||||||
Push-Location $repositoryRoot
|
Push-Location $repositoryRoot
|
||||||
try {
|
try {
|
||||||
& $resolvedUv run javis chat
|
if ($env:JAVIS_START_INTERFACE -eq 'gui') {
|
||||||
|
& $resolvedUv run javis gui
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& $resolvedUv run javis chat
|
||||||
|
}
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -2,16 +2,55 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore
|
from javis.memory.sqlite_store import ChatSession, SQLiteSessionStore
|
||||||
from javis.providers.base import ChatMessage, LocalModelProvider
|
from javis.providers.base import (
|
||||||
|
ChatMessage,
|
||||||
|
InvalidProviderResponseError,
|
||||||
|
LocalModelProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SessionProviderMismatchError(RuntimeError):
|
class SessionProviderMismatchError(RuntimeError):
|
||||||
"""The active provider cannot safely continue the stored session."""
|
"""The active provider cannot safely continue the stored session."""
|
||||||
|
|
||||||
|
|
||||||
|
_SENSITIVE_TITLE_PATTERN = re.compile(
|
||||||
|
r"(?ix)"
|
||||||
|
r"\b(?:api[-_ ]?key|passwort|password|token|secret|iban|kreditkarte|"
|
||||||
|
r"private[-_ ]?key|ssh[-_ ]?key)\b|"
|
||||||
|
r"-----BEGIN [A-Z ]+PRIVATE KEY-----|"
|
||||||
|
r"\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b|"
|
||||||
|
r"\b[\w.+-]+@[\w.-]+\.[A-Z]{2,}\b"
|
||||||
|
)
|
||||||
|
_MEDICAL_TITLE_PATTERN = re.compile(
|
||||||
|
r"(?ix)\b(?:arzt|ärzt|blut(?:e|en|ung)?|diagnos|gesundheit|krank|"
|
||||||
|
r"medikament|notfall|schmerz|symptom|therap|verletz|wunde)\w*\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_session_title(text: str, *, limit: int = 60) -> str:
|
||||||
|
"""Create a short local title without retaining obvious sensitive values."""
|
||||||
|
|
||||||
|
normalized = " ".join(text.split())
|
||||||
|
if _SENSITIVE_TITLE_PATTERN.search(normalized):
|
||||||
|
return "Sensible Anfrage"
|
||||||
|
if _MEDICAL_TITLE_PATTERN.search(normalized):
|
||||||
|
return "Gesundheitsfrage"
|
||||||
|
cleaned = re.sub(r"[`*_#~]+", "", normalized).strip(" -:;,.!?")
|
||||||
|
if not cleaned:
|
||||||
|
return "Neue Unterhaltung"
|
||||||
|
if len(cleaned) <= limit:
|
||||||
|
return cleaned
|
||||||
|
shortened = cleaned[: limit - 1].rsplit(" ", 1)[0].rstrip(" -:;,.!?")
|
||||||
|
if not shortened:
|
||||||
|
shortened = cleaned[: limit - 1].rstrip()
|
||||||
|
return f"{shortened}…"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LoadedSession:
|
class LoadedSession:
|
||||||
session: ChatSession
|
session: ChatSession
|
||||||
@@ -33,6 +72,24 @@ class ChatService:
|
|||||||
def list_sessions(self) -> list[ChatSession]:
|
def list_sessions(self) -> list[ChatSession]:
|
||||||
return self.store.list_sessions()
|
return self.store.list_sessions()
|
||||||
|
|
||||||
|
def search_sessions(self, text: str) -> list[ChatSession]:
|
||||||
|
normalized = text.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Für die Suche fehlt ein Text.")
|
||||||
|
return self.store.search_sessions(normalized)
|
||||||
|
|
||||||
|
def resolve_session(self, reference: str) -> LoadedSession:
|
||||||
|
session = self.store.resolve_unique_prefix(reference)
|
||||||
|
return self.load_session(session.id)
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> ChatSession:
|
||||||
|
normalized = " ".join(title.split()).strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Der Sitzungstitel darf nicht leer sein.")
|
||||||
|
if len(normalized) > 60:
|
||||||
|
normalized = f"{normalized[:59].rstrip()}…"
|
||||||
|
return self.store.rename_session(session_id, normalized)
|
||||||
|
|
||||||
def load_session(self, session_id: str) -> LoadedSession:
|
def load_session(self, session_id: str) -> LoadedSession:
|
||||||
session = self.store.get_session(session_id)
|
session = self.store.get_session(session_id)
|
||||||
supports_session = getattr(self.provider, "supports_session", None)
|
supports_session = getattr(self.provider, "supports_session", None)
|
||||||
@@ -56,8 +113,63 @@ class ChatService:
|
|||||||
loaded = self.load_session(session_id)
|
loaded = self.load_session(session_id)
|
||||||
messages = [*loaded.messages, ChatMessage("user", normalized)]
|
messages = [*loaded.messages, ChatMessage("user", normalized)]
|
||||||
response = self.provider.chat(messages)
|
response = self.provider.chat(messages)
|
||||||
self.store.append_exchange(session_id, normalized, response)
|
self.store.append_exchange(
|
||||||
|
session_id,
|
||||||
|
normalized,
|
||||||
|
response,
|
||||||
|
title_if_first=derive_session_title(normalized),
|
||||||
|
last_provider=self._last_provider_name(),
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def stream_send(self, session_id: str, text: str) -> Iterator[str]:
|
||||||
|
normalized = text.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Eine leere Nachricht wird nicht gesendet.")
|
||||||
|
loaded = self.load_session(session_id)
|
||||||
|
messages = [*loaded.messages, ChatMessage("user", normalized)]
|
||||||
|
stream_method = getattr(self.provider, "stream_chat", None)
|
||||||
|
stream = (
|
||||||
|
stream_method(messages)
|
||||||
|
if callable(stream_method)
|
||||||
|
else iter((self.provider.chat(messages),))
|
||||||
|
)
|
||||||
|
chunks: list[str] = []
|
||||||
|
completed = False
|
||||||
|
try:
|
||||||
|
for chunk in stream:
|
||||||
|
if not isinstance(chunk, str):
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Der Provider lieferte einen ungültigen Streaming-Abschnitt."
|
||||||
|
)
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
chunks.append(chunk)
|
||||||
|
yield chunk
|
||||||
|
completed = True
|
||||||
|
finally:
|
||||||
|
if not completed:
|
||||||
|
close_stream = getattr(stream, "close", None)
|
||||||
|
if callable(close_stream):
|
||||||
|
close_stream()
|
||||||
|
|
||||||
|
response = "".join(chunks).strip()
|
||||||
|
if not response:
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Der Provider lieferte keine verwendbare Streaming-Antwort."
|
||||||
|
)
|
||||||
|
self.store.append_exchange(
|
||||||
|
session_id,
|
||||||
|
normalized,
|
||||||
|
response,
|
||||||
|
title_if_first=derive_session_title(normalized),
|
||||||
|
last_provider=self._last_provider_name(),
|
||||||
|
)
|
||||||
|
|
||||||
def clear_session(self, session_id: str) -> None:
|
def clear_session(self, session_id: str) -> None:
|
||||||
self.store.clear_messages(session_id)
|
self.store.clear_messages(session_id)
|
||||||
|
|
||||||
|
def _last_provider_name(self) -> str:
|
||||||
|
route = getattr(self.provider, "last_route", None)
|
||||||
|
provider_name = getattr(route, "provider", None)
|
||||||
|
return provider_name if isinstance(provider_name, str) else self.provider.name
|
||||||
|
|||||||
@@ -2,21 +2,31 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
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 (
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
InvalidApiKeyError,
|
InvalidApiKeyError,
|
||||||
|
InvalidProviderResponseError,
|
||||||
LocalModelProvider,
|
LocalModelProvider,
|
||||||
MissingApiKeyError,
|
MissingApiKeyError,
|
||||||
ProviderError,
|
ProviderError,
|
||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
|
ResponseAbortedError,
|
||||||
)
|
)
|
||||||
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]
|
||||||
|
|
||||||
@@ -87,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"
|
||||||
@@ -108,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()
|
||||||
@@ -165,6 +186,248 @@ class HybridProvider:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
|
||||||
|
current_text = messages[-1].content if messages else ""
|
||||||
|
decision = self._privacy_router.classify(
|
||||||
|
current_text,
|
||||||
|
cloud_requested=self.mode == "gemini",
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.mode == "local":
|
||||||
|
yield from self._stream_local(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
"lokaler Modus",
|
||||||
|
fallback=False,
|
||||||
|
cloud_suppressed_by_local_mode=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if decision.policy is CloudPolicy.NEVER:
|
||||||
|
yield from self._stream_local(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
decision.reason,
|
||||||
|
fallback=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
approved = decision.policy is CloudPolicy.ALLOWED
|
||||||
|
if decision.policy is CloudPolicy.ASK:
|
||||||
|
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,
|
||||||
|
"Cloudfreigabe abgelehnt",
|
||||||
|
fallback=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
unavailable_reason = self._cloud_unavailable_reason()
|
||||||
|
if unavailable_reason:
|
||||||
|
yield from self._stream_local(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
unavailable_reason,
|
||||||
|
fallback=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if len(current_text) > self._max_cloud_input_chars:
|
||||||
|
yield from self._stream_local(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
"Nachricht überschreitet das lokale Cloud-Größenlimit",
|
||||||
|
fallback=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cloud_messages = self._privacy_router.minimal_context(
|
||||||
|
messages,
|
||||||
|
current_policy=decision.policy,
|
||||||
|
approved=approved,
|
||||||
|
max_chars=self._max_cloud_input_chars,
|
||||||
|
max_messages=self._max_cloud_context_messages,
|
||||||
|
)
|
||||||
|
if not cloud_messages:
|
||||||
|
yield from self._stream_local(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
"kein freigegebener Cloudkontext",
|
||||||
|
fallback=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
emitted = False
|
||||||
|
try:
|
||||||
|
cloud_provider = self._cloud_provider_factory()
|
||||||
|
for chunk in self._provider_chunks(cloud_provider, cloud_messages):
|
||||||
|
emitted = True
|
||||||
|
yield chunk
|
||||||
|
except InvalidApiKeyError as exc:
|
||||||
|
self._cloud_session_disabled = True
|
||||||
|
if emitted:
|
||||||
|
self._raise_interrupted_cloud(decision, exc)
|
||||||
|
yield from self._stream_cloud_failure(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
exc,
|
||||||
|
disable_notice=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except (MissingApiKeyError, ProviderError) as exc:
|
||||||
|
if emitted:
|
||||||
|
self._raise_interrupted_cloud(decision, exc)
|
||||||
|
yield from self._stream_cloud_failure(messages, decision, exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not emitted:
|
||||||
|
error = InvalidProviderResponseError(
|
||||||
|
"Gemini hat keine verwendbare Streaming-Antwort geliefert."
|
||||||
|
)
|
||||||
|
yield from self._stream_cloud_failure(messages, decision, error)
|
||||||
|
return
|
||||||
|
|
||||||
|
usage = getattr(cloud_provider, "last_usage", ProviderUsage())
|
||||||
|
self._record(
|
||||||
|
ProviderEvent(
|
||||||
|
provider=cloud_provider.name,
|
||||||
|
model=cloud_provider.model,
|
||||||
|
success=True,
|
||||||
|
error_category=None,
|
||||||
|
fallback=False,
|
||||||
|
privacy_policy=decision.policy.value,
|
||||||
|
input_tokens=usage.input_tokens,
|
||||||
|
output_tokens=usage.output_tokens,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.last_route = RouteStatus(
|
||||||
|
provider=cloud_provider.name,
|
||||||
|
privacy_policy=decision.policy,
|
||||||
|
fallback=False,
|
||||||
|
cloud_suppressed_by_local_mode=False,
|
||||||
|
technical_fallback=False,
|
||||||
|
reason="Cloudstream erfolgreich",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _provider_chunks(
|
||||||
|
provider: LocalModelProvider,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
) -> Iterator[str]:
|
||||||
|
stream_method = getattr(provider, "stream_chat", None)
|
||||||
|
chunks = (
|
||||||
|
stream_method(messages) if callable(stream_method) else iter((provider.chat(messages),))
|
||||||
|
)
|
||||||
|
completed = False
|
||||||
|
try:
|
||||||
|
for chunk in chunks:
|
||||||
|
if not isinstance(chunk, str):
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Der Provider lieferte einen ungültigen Streaming-Abschnitt."
|
||||||
|
)
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
completed = True
|
||||||
|
finally:
|
||||||
|
if not completed:
|
||||||
|
close = getattr(chunks, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
|
def _stream_cloud_failure(
|
||||||
|
self,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
decision: PrivacyDecision,
|
||||||
|
error: ProviderError,
|
||||||
|
*,
|
||||||
|
disable_notice: bool = False,
|
||||||
|
) -> Iterator[str]:
|
||||||
|
self._record_cloud_error(decision, error)
|
||||||
|
reason = "Gemini nicht verfügbar"
|
||||||
|
if disable_notice:
|
||||||
|
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
|
||||||
|
yield from self._stream_local(
|
||||||
|
messages,
|
||||||
|
decision,
|
||||||
|
reason,
|
||||||
|
fallback=True,
|
||||||
|
technical_fallback=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise_interrupted_cloud(
|
||||||
|
self,
|
||||||
|
decision: PrivacyDecision,
|
||||||
|
error: ProviderError,
|
||||||
|
) -> None:
|
||||||
|
self._record_cloud_error(decision, error)
|
||||||
|
self.last_route = RouteStatus(
|
||||||
|
provider="gemini",
|
||||||
|
privacy_policy=decision.policy,
|
||||||
|
fallback=False,
|
||||||
|
cloud_suppressed_by_local_mode=False,
|
||||||
|
technical_fallback=True,
|
||||||
|
reason="Cloudstream nach Teilausgabe abgebrochen",
|
||||||
|
)
|
||||||
|
raise ResponseAbortedError(
|
||||||
|
"Der Gemini-Stream wurde nach einer Teilausgabe abgebrochen; "
|
||||||
|
"es wurde kein lokaler Ersatz angehängt."
|
||||||
|
) from error
|
||||||
|
|
||||||
|
def _stream_local(
|
||||||
|
self,
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
decision: PrivacyDecision,
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
fallback: bool,
|
||||||
|
cloud_suppressed_by_local_mode: bool = False,
|
||||||
|
technical_fallback: bool = False,
|
||||||
|
) -> Iterator[str]:
|
||||||
|
if fallback:
|
||||||
|
self._notice_callback(f"{reason} – lokale Antwort mit Ollama.")
|
||||||
|
emitted = False
|
||||||
|
try:
|
||||||
|
for chunk in self._provider_chunks(self.local_provider, messages):
|
||||||
|
emitted = True
|
||||||
|
yield chunk
|
||||||
|
except ProviderError as exc:
|
||||||
|
self._record(
|
||||||
|
ProviderEvent(
|
||||||
|
provider=self.local_provider.name,
|
||||||
|
model=self.local_provider.model,
|
||||||
|
success=False,
|
||||||
|
error_category=type(exc).__name__,
|
||||||
|
fallback=fallback,
|
||||||
|
privacy_policy=decision.policy.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
if not emitted:
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Ollama hat keine verwendbare Streaming-Antwort geliefert."
|
||||||
|
)
|
||||||
|
self._record(
|
||||||
|
ProviderEvent(
|
||||||
|
provider=self.local_provider.name,
|
||||||
|
model=self.local_provider.model,
|
||||||
|
success=True,
|
||||||
|
error_category=None,
|
||||||
|
fallback=fallback,
|
||||||
|
privacy_policy=decision.policy.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.last_route = RouteStatus(
|
||||||
|
provider=self.local_provider.name,
|
||||||
|
privacy_policy=decision.policy,
|
||||||
|
fallback=fallback,
|
||||||
|
cloud_suppressed_by_local_mode=cloud_suppressed_by_local_mode,
|
||||||
|
technical_fallback=technical_fallback,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
def _cloud_unavailable_reason(self) -> str | None:
|
def _cloud_unavailable_reason(self) -> str | None:
|
||||||
if not self._cloud_enabled:
|
if not self._cloud_enabled:
|
||||||
return "Gemini ist lokal nicht aktiviert"
|
return "Gemini ist lokal nicht aktiviert"
|
||||||
@@ -188,17 +451,7 @@ class HybridProvider:
|
|||||||
*,
|
*,
|
||||||
disable_notice: bool = False,
|
disable_notice: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
error_category = type(error).__name__
|
self._record_cloud_error(decision, error)
|
||||||
self._record(
|
|
||||||
ProviderEvent(
|
|
||||||
provider="gemini",
|
|
||||||
model=self._cloud_model,
|
|
||||||
success=False,
|
|
||||||
error_category=error_category,
|
|
||||||
fallback=False,
|
|
||||||
privacy_policy=decision.policy.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
reason = "Gemini nicht verfügbar"
|
reason = "Gemini nicht verfügbar"
|
||||||
if disable_notice:
|
if disable_notice:
|
||||||
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
|
reason = "Gemini-Schlüssel abgelehnt; Cloud für diese Sitzung deaktiviert"
|
||||||
@@ -210,6 +463,22 @@ class HybridProvider:
|
|||||||
technical_fallback=True,
|
technical_fallback=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _record_cloud_error(
|
||||||
|
self,
|
||||||
|
decision: PrivacyDecision,
|
||||||
|
error: ProviderError,
|
||||||
|
) -> None:
|
||||||
|
self._record(
|
||||||
|
ProviderEvent(
|
||||||
|
provider="gemini",
|
||||||
|
model=self._cloud_model,
|
||||||
|
success=False,
|
||||||
|
error_category=type(error).__name__,
|
||||||
|
fallback=False,
|
||||||
|
privacy_policy=decision.policy.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _local(
|
def _local(
|
||||||
self,
|
self,
|
||||||
messages: list[ChatMessage],
|
messages: list[ChatMessage],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
+130
-80
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,28 +12,28 @@ 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,
|
||||||
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]
|
||||||
OutputFunction = Callable[[str], None]
|
OutputFunction = Callable[[str], None]
|
||||||
|
StreamOutputFunction = Callable[[str], None]
|
||||||
StatusFunction = Callable[[], list[str]]
|
StatusFunction = Callable[[], list[str]]
|
||||||
|
|
||||||
HELP_TEXT = """Befehle:
|
HELP_TEXT = """Befehle:
|
||||||
/new neue Sitzung beginnen
|
/new neue Sitzung beginnen
|
||||||
/sessions gespeicherte Sitzungen auflisten
|
/sessions gespeicherte Sitzungen auflisten
|
||||||
/load <ID> frühere Sitzung laden und fortsetzen
|
/load <NR|ID> Sitzung per Listennummer oder eindeutigem ID-Anfang laden
|
||||||
|
/search <TEXT> Titel und eigene Nachrichten durchsuchen
|
||||||
|
/rename <TITEL> aktive Sitzung umbenennen
|
||||||
/clear Nachrichten der aktiven Sitzung leeren
|
/clear Nachrichten der aktiven Sitzung leeren
|
||||||
/provider aktiven Providermodus anzeigen
|
/provider aktiven Providermodus anzeigen
|
||||||
/provider <MODUS> Modus auto, local oder gemini setzen
|
/provider <MODUS> Modus auto, local oder gemini setzen
|
||||||
@@ -47,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",
|
||||||
@@ -137,19 +139,56 @@ def _show_sessions(sessions: list[ChatSession], output: OutputFunction) -> None:
|
|||||||
if not sessions:
|
if not sessions:
|
||||||
output("Keine gespeicherten Sitzungen vorhanden.")
|
output("Keine gespeicherten Sitzungen vorhanden.")
|
||||||
return
|
return
|
||||||
for session in sessions:
|
for number, session in enumerate(sessions, start=1):
|
||||||
|
date = session.updated_at[:10]
|
||||||
output(
|
output(
|
||||||
f"{session.id} | {session.updated_at} | "
|
f"{number}. {session.id[:8]} | {session.title} | {date} | "
|
||||||
f"{session.provider}/{session.model} | {session.message_count} Nachrichten"
|
f"{session.message_count} Nachrichten | {session.last_provider}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalMarkdownRenderer:
|
||||||
|
"""Turn common Markdown markers into readable incremental terminal text."""
|
||||||
|
|
||||||
|
_tail_size = 3
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pending = ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean(text: str) -> str:
|
||||||
|
text = re.sub(r"(?m)^[ \t]{0,3}#{1,6}[ \t]+", "", text)
|
||||||
|
for marker in ("```", "**", "__", "~~", "`"):
|
||||||
|
text = text.replace(marker, "")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def feed(self, chunk: str) -> str:
|
||||||
|
combined = self._pending + chunk
|
||||||
|
if len(combined) <= self._tail_size:
|
||||||
|
self._pending = combined
|
||||||
|
return ""
|
||||||
|
visible = combined[: -self._tail_size]
|
||||||
|
self._pending = combined[-self._tail_size :]
|
||||||
|
return self._clean(visible)
|
||||||
|
|
||||||
|
def finish(self) -> str:
|
||||||
|
visible = self._clean(self._pending)
|
||||||
|
self._pending = ""
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_write(text: str) -> None:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
def run_chat(
|
def run_chat(
|
||||||
service: ChatService,
|
service: ChatService,
|
||||||
*,
|
*,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
input_fn: InputFunction = input,
|
input_fn: InputFunction = input,
|
||||||
output: OutputFunction = print,
|
output: OutputFunction = print,
|
||||||
|
stream_output: StreamOutputFunction | None = None,
|
||||||
status_fn: StatusFunction | None = None,
|
status_fn: StatusFunction | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
try:
|
try:
|
||||||
@@ -169,6 +208,7 @@ def run_chat(
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
output("Lokaler Javis-Chat. /help zeigt die Befehle.")
|
output("Lokaler Javis-Chat. /help zeigt die Befehle.")
|
||||||
|
shown_sessions: list[ChatSession] = []
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -194,10 +234,33 @@ def run_chat(
|
|||||||
continue
|
continue
|
||||||
if entered == "/sessions":
|
if entered == "/sessions":
|
||||||
try:
|
try:
|
||||||
_show_sessions(service.list_sessions(), output)
|
shown_sessions = service.list_sessions()
|
||||||
|
_show_sessions(shown_sessions, output)
|
||||||
except SessionStoreError as exc:
|
except SessionStoreError as exc:
|
||||||
output(f"Fehler: {exc}")
|
output(f"Fehler: {exc}")
|
||||||
continue
|
continue
|
||||||
|
if entered.startswith("/search"):
|
||||||
|
parts = entered.split(maxsplit=1)
|
||||||
|
if len(parts) != 2 or not parts[1].strip():
|
||||||
|
output("Verwendung: /search <Text>")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shown_sessions = service.search_sessions(parts[1])
|
||||||
|
_show_sessions(shown_sessions, output)
|
||||||
|
except (SessionStoreError, ValueError) as exc:
|
||||||
|
output(f"Fehler: {exc}")
|
||||||
|
continue
|
||||||
|
if entered.startswith("/rename"):
|
||||||
|
parts = entered.split(maxsplit=1)
|
||||||
|
if len(parts) != 2 or not parts[1].strip():
|
||||||
|
output("Verwendung: /rename <neuer Titel>")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
renamed = service.rename_session(active_id, parts[1])
|
||||||
|
output(f"Sitzung umbenannt: {renamed.title}")
|
||||||
|
except (SessionNotFoundError, SessionStoreError, ValueError) as exc:
|
||||||
|
output(f"Fehler: {exc}")
|
||||||
|
continue
|
||||||
if entered == "/clear":
|
if entered == "/clear":
|
||||||
try:
|
try:
|
||||||
service.clear_session(active_id)
|
service.clear_session(active_id)
|
||||||
@@ -247,12 +310,24 @@ def run_chat(
|
|||||||
output("Verwendung: /load <Sitzungs-ID>")
|
output("Verwendung: /load <Sitzungs-ID>")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
loaded = service.load_session(parts[1].strip())
|
reference = parts[1].strip()
|
||||||
|
if reference.isdecimal():
|
||||||
|
number = int(reference)
|
||||||
|
if not shown_sessions:
|
||||||
|
output("Zuerst /sessions oder /search anzeigen.")
|
||||||
|
continue
|
||||||
|
if number < 1 or number > len(shown_sessions):
|
||||||
|
output("Diese Listennummer ist nicht vorhanden.")
|
||||||
|
continue
|
||||||
|
loaded = service.load_session(shown_sessions[number - 1].id)
|
||||||
|
else:
|
||||||
|
loaded = service.resolve_session(reference)
|
||||||
active_id = loaded.session.id
|
active_id = loaded.session.id
|
||||||
output(f"Sitzung geladen: {active_id}")
|
output(f"Sitzung geladen: {active_id}")
|
||||||
_show_history(service, active_id, output)
|
_show_history(service, active_id, output)
|
||||||
except (
|
except (
|
||||||
SessionNotFoundError,
|
SessionNotFoundError,
|
||||||
|
AmbiguousSessionReferenceError,
|
||||||
SessionProviderMismatchError,
|
SessionProviderMismatchError,
|
||||||
SessionStoreError,
|
SessionStoreError,
|
||||||
) as exc:
|
) as exc:
|
||||||
@@ -262,12 +337,41 @@ def run_chat(
|
|||||||
output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.")
|
output("Unbekannter Befehl. /help zeigt die verfügbaren Befehle.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
stream = service.stream_send(active_id, entered)
|
||||||
|
writer = stream_output or (_terminal_write if output is print else output)
|
||||||
|
renderer = TerminalMarkdownRenderer()
|
||||||
|
started = False
|
||||||
try:
|
try:
|
||||||
response = service.send(active_id, entered)
|
for chunk in stream:
|
||||||
output(f"Javis: {response}")
|
visible = renderer.feed(chunk)
|
||||||
|
if not visible:
|
||||||
|
continue
|
||||||
|
if not started:
|
||||||
|
writer("Javis: ")
|
||||||
|
started = True
|
||||||
|
writer(visible)
|
||||||
|
remaining = renderer.finish()
|
||||||
|
if remaining:
|
||||||
|
if not started:
|
||||||
|
writer("Javis: ")
|
||||||
|
started = True
|
||||||
|
writer(remaining)
|
||||||
|
if started:
|
||||||
|
writer("\n")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
close = getattr(stream, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
if started:
|
||||||
|
writer("\n")
|
||||||
|
output("Generierung abgebrochen. Die unvollständige Antwort wurde nicht gespeichert.")
|
||||||
except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc:
|
except (ProviderError, SessionStoreError, SessionProviderMismatchError) as exc:
|
||||||
|
if started:
|
||||||
|
writer("\n")
|
||||||
output(f"Fehler: {exc}")
|
output(f"Fehler: {exc}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
if started:
|
||||||
|
writer("\n")
|
||||||
output(f"Fehler: {exc}")
|
output(f"Fehler: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@@ -278,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(
|
||||||
@@ -377,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
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class SessionNotFoundError(SessionStoreError):
|
|||||||
"""A requested session ID does not exist."""
|
"""A requested session ID does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiguousSessionReferenceError(SessionStoreError):
|
||||||
|
"""A short session reference matches multiple sessions."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ChatSession:
|
class ChatSession:
|
||||||
id: str
|
id: str
|
||||||
@@ -28,9 +32,14 @@ class ChatSession:
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
provider: str
|
provider: str
|
||||||
model: str
|
model: str
|
||||||
|
title: str
|
||||||
|
last_provider: str
|
||||||
message_count: int = 0
|
message_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SESSION_TITLE = "Neue Sitzung"
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> str:
|
def _utc_now() -> str:
|
||||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||||
|
|
||||||
@@ -63,16 +72,21 @@ class SQLiteSessionStore:
|
|||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
with self._connection() as connection:
|
with self._connection() as connection:
|
||||||
connection.executescript(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
model TEXT NOT NULL
|
model TEXT NOT NULL,
|
||||||
);
|
title TEXT NOT NULL DEFAULT 'Neue Sitzung',
|
||||||
|
last_provider TEXT NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id TEXT NOT NULL,
|
session_id TEXT NOT NULL,
|
||||||
@@ -82,10 +96,31 @@ class SQLiteSessionStore:
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
UNIQUE (session_id, position)
|
UNIQUE (session_id, position)
|
||||||
);
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
columns = {
|
||||||
|
row["name"] for row in connection.execute("PRAGMA table_info(sessions)").fetchall()
|
||||||
|
}
|
||||||
|
if "title" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT 'Neue Sitzung'"
|
||||||
|
)
|
||||||
|
if "last_provider" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE sessions ADD COLUMN last_provider TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET last_provider = provider
|
||||||
|
WHERE last_provider = ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session_position
|
CREATE INDEX IF NOT EXISTS idx_messages_session_position
|
||||||
ON messages(session_id, position);
|
ON messages(session_id, position)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,14 +131,31 @@ class SQLiteSessionStore:
|
|||||||
with self._connection() as connection:
|
with self._connection() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (id, created_at, updated_at, provider, model)
|
INSERT INTO sessions
|
||||||
VALUES (?, ?, ?, ?, ?)
|
(id, created_at, updated_at, provider, model, title, last_provider)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(session_id, now, now, provider, model),
|
(
|
||||||
|
session_id,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
DEFAULT_SESSION_TITLE,
|
||||||
|
provider,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc
|
raise SessionStoreError("Die Sitzung konnte nicht gespeichert werden.") from exc
|
||||||
return ChatSession(session_id, now, now, provider, model)
|
return ChatSession(
|
||||||
|
session_id,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
DEFAULT_SESSION_TITLE,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> ChatSession:
|
def get_session(self, session_id: str) -> ChatSession:
|
||||||
try:
|
try:
|
||||||
@@ -140,6 +192,84 @@ class SQLiteSessionStore:
|
|||||||
raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc
|
raise SessionStoreError("Die Sitzungen konnten nicht aufgelistet werden.") from exc
|
||||||
return [self._session_from_row(row) for row in rows]
|
return [self._session_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def search_sessions(self, text: str, *, limit: int = 20) -> list[ChatSession]:
|
||||||
|
normalized = text.strip()
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
escaped = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
pattern = f"%{escaped}%"
|
||||||
|
try:
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT s.*, COUNT(m.id) AS message_count
|
||||||
|
FROM sessions AS s
|
||||||
|
LEFT JOIN messages AS m ON m.session_id = s.id
|
||||||
|
WHERE s.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM messages AS searched
|
||||||
|
WHERE searched.session_id = s.id
|
||||||
|
AND searched.role = 'user'
|
||||||
|
AND searched.content LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
|
)
|
||||||
|
GROUP BY s.id
|
||||||
|
ORDER BY s.updated_at DESC, s.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(pattern, pattern, max(1, min(limit, 100))),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise SessionStoreError("Die Sitzungssuche ist fehlgeschlagen.") from exc
|
||||||
|
return [self._session_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def resolve_unique_prefix(self, prefix: str) -> ChatSession:
|
||||||
|
normalized = prefix.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise SessionNotFoundError("Leerer Sitzungsverweis.")
|
||||||
|
escaped = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
try:
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM sessions
|
||||||
|
WHERE id LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 2
|
||||||
|
""",
|
||||||
|
(f"{escaped}%",),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise SessionStoreError("Der Sitzungsverweis konnte nicht geprüft werden.") from exc
|
||||||
|
if not rows:
|
||||||
|
raise SessionNotFoundError(f"Keine Sitzung beginnt mit '{normalized}'.")
|
||||||
|
if len(rows) > 1:
|
||||||
|
raise AmbiguousSessionReferenceError(
|
||||||
|
f"Der Sitzungsanfang '{normalized}' ist nicht eindeutig."
|
||||||
|
)
|
||||||
|
return self.get_session(rows[0]["id"])
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> ChatSession:
|
||||||
|
now = _utc_now()
|
||||||
|
try:
|
||||||
|
with self._connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET title = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(title, now, session_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise SessionNotFoundError(f"Sitzung '{session_id}' wurde nicht gefunden.")
|
||||||
|
except SessionNotFoundError:
|
||||||
|
raise
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise SessionStoreError("Die Sitzung konnte nicht umbenannt werden.") from exc
|
||||||
|
return self.get_session(session_id)
|
||||||
|
|
||||||
def get_messages(self, session_id: str) -> list[ChatMessage]:
|
def get_messages(self, session_id: str) -> list[ChatMessage]:
|
||||||
self.get_session(session_id)
|
self.get_session(session_id)
|
||||||
try:
|
try:
|
||||||
@@ -162,6 +292,9 @@ class SQLiteSessionStore:
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
user_content: str,
|
user_content: str,
|
||||||
assistant_content: str,
|
assistant_content: str,
|
||||||
|
*,
|
||||||
|
title_if_first: str | None = None,
|
||||||
|
last_provider: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
now = _utc_now()
|
now = _utc_now()
|
||||||
try:
|
try:
|
||||||
@@ -194,8 +327,24 @@ class SQLiteSessionStore:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE sessions SET updated_at = ? WHERE id = ?",
|
"""
|
||||||
(now, session_id),
|
UPDATE sessions
|
||||||
|
SET updated_at = ?,
|
||||||
|
title = CASE
|
||||||
|
WHEN ? = 0 AND title = ? THEN COALESCE(?, title)
|
||||||
|
ELSE title
|
||||||
|
END,
|
||||||
|
last_provider = COALESCE(?, last_provider)
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
now,
|
||||||
|
next_position,
|
||||||
|
DEFAULT_SESSION_TITLE,
|
||||||
|
title_if_first,
|
||||||
|
last_provider,
|
||||||
|
session_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except SessionNotFoundError:
|
except SessionNotFoundError:
|
||||||
raise
|
raise
|
||||||
@@ -232,5 +381,7 @@ class SQLiteSessionStore:
|
|||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
provider=row["provider"],
|
provider=row["provider"],
|
||||||
model=row["model"],
|
model=row["model"],
|
||||||
|
title=row["title"],
|
||||||
|
last_provider=row["last_provider"],
|
||||||
message_count=row["message_count"],
|
message_count=row["message_count"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
@@ -70,3 +71,13 @@ class LocalModelProvider(Protocol):
|
|||||||
def chat(self, messages: list[ChatMessage]) -> str:
|
def chat(self, messages: list[ChatMessage]) -> str:
|
||||||
"""Return one complete assistant response."""
|
"""Return one complete assistant response."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class StreamingModelProvider(Protocol):
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
|
||||||
|
"""Yield only visible assistant text chunks."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -49,6 +49,7 @@ class GeminiProvider:
|
|||||||
self._config = types.GenerateContentConfig(
|
self._config = types.GenerateContentConfig(
|
||||||
candidate_count=1,
|
candidate_count=1,
|
||||||
max_output_tokens=max_output_tokens,
|
max_output_tokens=max_output_tokens,
|
||||||
|
thinking_config=types.ThinkingConfig(include_thoughts=False),
|
||||||
)
|
)
|
||||||
self._client = client_factory(
|
self._client = client_factory(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -104,6 +105,73 @@ class GeminiProvider:
|
|||||||
)
|
)
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
|
||||||
|
contents = [
|
||||||
|
types.Content(
|
||||||
|
role="model" if message.role == "assistant" else "user",
|
||||||
|
parts=[types.Part.from_text(text=message.content)],
|
||||||
|
)
|
||||||
|
for message in messages
|
||||||
|
]
|
||||||
|
self.last_usage = ProviderUsage()
|
||||||
|
visible_text_received = False
|
||||||
|
try:
|
||||||
|
responses = self._client.models.generate_content_stream(
|
||||||
|
model=self.model,
|
||||||
|
contents=contents,
|
||||||
|
config=self._config,
|
||||||
|
)
|
||||||
|
for response in responses:
|
||||||
|
usage = getattr(response, "usage_metadata", None)
|
||||||
|
if usage is not None:
|
||||||
|
self.last_usage = ProviderUsage(
|
||||||
|
input_tokens=self._token_count(usage, "prompt_token_count"),
|
||||||
|
output_tokens=self._token_count(
|
||||||
|
usage,
|
||||||
|
"candidates_token_count",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for text in self._visible_text_parts(response):
|
||||||
|
visible_text_received = True
|
||||||
|
yield text
|
||||||
|
except errors.APIError as exc:
|
||||||
|
self._raise_api_error(exc)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderTimeoutError("Gemini hat nicht rechtzeitig geantwortet.") from exc
|
||||||
|
except httpx.NetworkError as exc:
|
||||||
|
raise CloudNetworkError(
|
||||||
|
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
|
||||||
|
) from exc
|
||||||
|
except (OSError, ConnectionError) as exc:
|
||||||
|
raise CloudNetworkError(
|
||||||
|
"Gemini ist wegen eines Netzwerkfehlers nicht erreichbar."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not visible_text_received:
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Gemini hat keine verwendbare Streaming-Antwort geliefert."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _visible_text_parts(response: object) -> Iterator[str]:
|
||||||
|
candidates = getattr(response, "candidates", None)
|
||||||
|
if isinstance(candidates, list) and candidates:
|
||||||
|
for candidate in candidates:
|
||||||
|
content = getattr(candidate, "content", None)
|
||||||
|
parts = getattr(content, "parts", None)
|
||||||
|
if not isinstance(parts, list):
|
||||||
|
continue
|
||||||
|
for part in parts:
|
||||||
|
if getattr(part, "thought", False):
|
||||||
|
continue
|
||||||
|
text = getattr(part, "text", None)
|
||||||
|
if isinstance(text, str) and text:
|
||||||
|
yield text
|
||||||
|
return
|
||||||
|
text = getattr(response, "text", None)
|
||||||
|
if isinstance(text, str) and text:
|
||||||
|
yield text
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _token_count(usage: object, name: str) -> int | None:
|
def _token_count(usage: object, name: str) -> int | None:
|
||||||
value = getattr(usage, name, None)
|
value = getattr(usage, name, None)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
@@ -107,3 +108,76 @@ class OllamaProvider:
|
|||||||
"Ollama hat keine verwendbare Textantwort geliefert."
|
"Ollama hat keine verwendbare Textantwort geliefert."
|
||||||
)
|
)
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]) -> Iterator[str]:
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": message.role, "content": message.content} for message in messages
|
||||||
|
],
|
||||||
|
"stream": True,
|
||||||
|
"think": False,
|
||||||
|
}
|
||||||
|
request = Request(
|
||||||
|
self._endpoint,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
completed = False
|
||||||
|
visible_text_received = False
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=self._timeout_seconds) as response:
|
||||||
|
for raw_line in response:
|
||||||
|
if not raw_line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = json.loads(raw_line)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Ollama hat einen ungültigen Streaming-Abschnitt geliefert."
|
||||||
|
) from exc
|
||||||
|
error = result.get("error")
|
||||||
|
if isinstance(error, str) and error:
|
||||||
|
if "not found" in error.lower():
|
||||||
|
raise ModelNotInstalledError(
|
||||||
|
f"Das lokale Modell '{self.model}' ist nicht installiert."
|
||||||
|
)
|
||||||
|
raise ProviderUnavailableError("Ollama hat den Stream abgelehnt.")
|
||||||
|
content = result.get("message", {}).get("content")
|
||||||
|
if content is not None and not isinstance(content, str):
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Ollama hat ungültigen sichtbaren Text geliefert."
|
||||||
|
)
|
||||||
|
if content:
|
||||||
|
visible_text_received = True
|
||||||
|
yield content
|
||||||
|
if result.get("done") is True:
|
||||||
|
completed = True
|
||||||
|
break
|
||||||
|
except HTTPError as exc:
|
||||||
|
details = exc.read().decode("utf-8", errors="replace")
|
||||||
|
if exc.code == 404 or "not found" in details.lower():
|
||||||
|
raise ModelNotInstalledError(
|
||||||
|
f"Das lokale Modell '{self.model}' ist nicht installiert."
|
||||||
|
) from exc
|
||||||
|
raise ProviderUnavailableError(f"Ollama meldet HTTP-Fehler {exc.code}.") from exc
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise ProviderTimeoutError(
|
||||||
|
"Die Modellantwort hat das Zeitlimit überschritten."
|
||||||
|
) from exc
|
||||||
|
except URLError as exc:
|
||||||
|
if isinstance(exc.reason, (TimeoutError, socket.timeout)):
|
||||||
|
raise ProviderTimeoutError(
|
||||||
|
"Die Modellantwort hat das Zeitlimit überschritten."
|
||||||
|
) from exc
|
||||||
|
raise ProviderUnavailableError(
|
||||||
|
"Ollama ist unter der konfigurierten lokalen Adresse nicht erreichbar."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not completed:
|
||||||
|
raise ResponseAbortedError("Der Ollama-Stream wurde vorzeitig beendet.")
|
||||||
|
if not visible_text_received:
|
||||||
|
raise InvalidProviderResponseError(
|
||||||
|
"Ollama hat keine verwendbare Streaming-Antwort geliefert."
|
||||||
|
)
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ _NEVER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
re.compile(
|
re.compile(
|
||||||
r"(?i)\b(diagnose|gesundheit|krankheit|medikament|patient|arztbericht|"
|
r"(?i)\b(symptom(?:e|en)?|blut(?:e|est|et|en|ung|ungen|wert|werte)?|"
|
||||||
r"psychotherapie|blutwert)\b"
|
r"verletz(?:ung|ungen|t)|wunde[n]?|medikament(?:e|en)?|diagnos(?:e|en|tiziert)|"
|
||||||
|
r"behandl(?:ung|ungen|e|en)|medizinisch(?:e[rsn]?|er)?\s+notfall|"
|
||||||
|
r"notaufnahme|rettungsdienst|gesundheit|krankheit(?:en)?|patient(?:en)?|"
|
||||||
|
r"arzt|ärzt(?:in|lich(?:e[rsn]?|er)?)|arztbericht|krankenhaus|"
|
||||||
|
r"psychotherapie|schmerz(?:en)?|fieber|atemnot|bewusstlos|ohnmacht|"
|
||||||
|
r"vergiftung|allergisch(?:e[rsn]?|er)?\s+reaktion)\b"
|
||||||
),
|
),
|
||||||
"Gesundheitsinformation",
|
"Gesundheitsinformation",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""PySide6 desktop interface for the existing Javis core."""
|
||||||
@@ -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())
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""UI-facing immutable models without Qt dependencies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationState(StrEnum):
|
||||||
|
OFFLINE = "Offline"
|
||||||
|
READY = "Bereit"
|
||||||
|
THINKING = "Denkt"
|
||||||
|
ANSWERING = "Antwortet"
|
||||||
|
ABORTED = "Abgebrochen"
|
||||||
|
ERROR = "Fehler"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEventKind(StrEnum):
|
||||||
|
STATE = "state"
|
||||||
|
CHUNK = "chunk"
|
||||||
|
NOTICE = "notice"
|
||||||
|
ERROR = "error"
|
||||||
|
FINISHED = "finished"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamEvent:
|
||||||
|
kind: StreamEventKind
|
||||||
|
text: str = ""
|
||||||
|
state: GenerationState | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DesktopStatus:
|
||||||
|
state: GenerationState
|
||||||
|
mode: str
|
||||||
|
provider: str
|
||||||
|
ollama_reachable: bool
|
||||||
|
local_model: str
|
||||||
|
privacy_policy: str
|
||||||
|
cloud_requests_today: int | None
|
||||||
|
local_fallback: bool
|
||||||
|
data_dir: str
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -2,9 +2,9 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from javis.core.chat_service import ChatService
|
from javis.core.chat_service import ChatService, derive_session_title
|
||||||
from javis.memory.sqlite_store import SQLiteSessionStore
|
from javis.memory.sqlite_store import SQLiteSessionStore
|
||||||
from javis.providers.base import ChatMessage, LocalModelProvider
|
from javis.providers.base import ChatMessage, LocalModelProvider, ProviderUnavailableError
|
||||||
|
|
||||||
|
|
||||||
class RecordingProvider:
|
class RecordingProvider:
|
||||||
@@ -19,6 +19,19 @@ class RecordingProvider:
|
|||||||
return f"Antwort {len(self.calls)}"
|
return f"Antwort {len(self.calls)}"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingProvider(RecordingProvider):
|
||||||
|
def __init__(self, *, fail_after_first: bool = False) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.fail_after_first = fail_after_first
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]):
|
||||||
|
self.calls.append(messages)
|
||||||
|
yield "Teil "
|
||||||
|
if self.fail_after_first:
|
||||||
|
raise ProviderUnavailableError("Streamfehler")
|
||||||
|
yield "Antwort"
|
||||||
|
|
||||||
|
|
||||||
class ChatServiceTests(unittest.TestCase):
|
class ChatServiceTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||||
@@ -51,6 +64,67 @@ class ChatServiceTests(unittest.TestCase):
|
|||||||
"Antwort 2",
|
"Antwort 2",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(loaded.session.title, "Hallo")
|
||||||
|
|
||||||
|
def test_title_is_local_short_and_redacts_obvious_sensitive_content(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
derive_session_title("Erkläre SQLite-Transaktionen für Anfänger"),
|
||||||
|
"Erkläre SQLite-Transaktionen für Anfänger",
|
||||||
|
)
|
||||||
|
self.assertLessEqual(derive_session_title("Wort " * 30).__len__(), 60)
|
||||||
|
self.assertEqual(
|
||||||
|
derive_session_title("Mein API-Key ist ABC123 und funktioniert nicht"),
|
||||||
|
"Sensible Anfrage",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
derive_session_title("Wenn ich blute, sollte ich zum Arzt?"),
|
||||||
|
"Gesundheitsfrage",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manual_title_is_normalized_and_limited(self) -> None:
|
||||||
|
session = self.service.new_session()
|
||||||
|
|
||||||
|
renamed = self.service.rename_session(session.id, " Mein Titel ")
|
||||||
|
shortened = self.service.rename_session(session.id, "x" * 80)
|
||||||
|
|
||||||
|
self.assertEqual(renamed.title, "Mein Titel")
|
||||||
|
self.assertEqual(len(shortened.title), 60)
|
||||||
|
self.assertTrue(shortened.title.endswith("…"))
|
||||||
|
|
||||||
|
def test_streaming_response_is_persisted_exactly_once_after_completion(self) -> None:
|
||||||
|
provider = StreamingProvider()
|
||||||
|
service = ChatService(self.service.store, provider)
|
||||||
|
session = service.new_session()
|
||||||
|
|
||||||
|
chunks = list(service.stream_send(session.id, "Hallo"))
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Teil ", "Antwort"])
|
||||||
|
self.assertEqual(
|
||||||
|
[message.content for message in service.load_session(session.id).messages],
|
||||||
|
["Hallo", "Teil Antwort"],
|
||||||
|
)
|
||||||
|
self.assertEqual(service.load_session(session.id).session.message_count, 2)
|
||||||
|
|
||||||
|
def test_closed_stream_does_not_persist_partial_response(self) -> None:
|
||||||
|
provider = StreamingProvider()
|
||||||
|
service = ChatService(self.service.store, provider)
|
||||||
|
session = service.new_session()
|
||||||
|
stream = service.stream_send(session.id, "Hallo")
|
||||||
|
|
||||||
|
self.assertEqual(next(stream), "Teil ")
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
self.assertEqual(service.load_session(session.id).messages, [])
|
||||||
|
|
||||||
|
def test_provider_error_during_stream_does_not_persist_partial_response(self) -> None:
|
||||||
|
provider = StreamingProvider(fail_after_first=True)
|
||||||
|
service = ChatService(self.service.store, provider)
|
||||||
|
session = service.new_session()
|
||||||
|
|
||||||
|
with self.assertRaises(ProviderUnavailableError):
|
||||||
|
list(service.stream_send(session.id, "Hallo"))
|
||||||
|
|
||||||
|
self.assertEqual(service.load_session(session.id).messages, [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+141
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -6,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
|
||||||
@@ -19,6 +25,22 @@ class _FakeProvider:
|
|||||||
def chat(self, messages: list[ChatMessage]) -> str:
|
def chat(self, messages: list[ChatMessage]) -> str:
|
||||||
return f"Echo: {messages[-1].content}"
|
return f"Echo: {messages[-1].content}"
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]):
|
||||||
|
yield "Echo: "
|
||||||
|
yield messages[-1].content
|
||||||
|
|
||||||
|
|
||||||
|
class _MarkdownProvider(_FakeProvider):
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]):
|
||||||
|
yield "## **Ant"
|
||||||
|
yield "wort**\n`SQLite`"
|
||||||
|
|
||||||
|
|
||||||
|
class _InterruptingProvider(_FakeProvider):
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]):
|
||||||
|
yield "angefangene Antwort"
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
class _FakeHybridProvider(_FakeProvider):
|
class _FakeHybridProvider(_FakeProvider):
|
||||||
name = "hybrid"
|
name = "hybrid"
|
||||||
@@ -45,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")
|
||||||
@@ -59,10 +85,53 @@ class CliTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result, 0)
|
self.assertEqual(result, 0)
|
||||||
self.assertTrue(any(line == "Javis: Echo: Hallo" for line in output))
|
self.assertIn("Javis: Echo: Hallo\n", "".join(output))
|
||||||
self.assertTrue(any("Nachrichten" in line for line in output))
|
self.assertTrue(any("Nachrichten" in line for line in output))
|
||||||
self.assertEqual(output[-1], "Chat beendet.")
|
self.assertEqual(output[-1], "Chat beendet.")
|
||||||
|
|
||||||
|
def test_streaming_cleans_terminal_markdown_but_persists_raw_answer(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||||
|
service = ChatService(store, _MarkdownProvider())
|
||||||
|
inputs = iter(["Frage", "/exit"])
|
||||||
|
output: list[str] = []
|
||||||
|
streamed: list[str] = []
|
||||||
|
|
||||||
|
result = run_chat(
|
||||||
|
service,
|
||||||
|
input_fn=lambda _prompt: next(inputs),
|
||||||
|
output=output.append,
|
||||||
|
stream_output=streamed.append,
|
||||||
|
)
|
||||||
|
session = service.list_sessions()[0]
|
||||||
|
messages = service.load_session(session.id).messages
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual("".join(streamed), "Javis: Antwort\nSQLite\n")
|
||||||
|
self.assertEqual(messages[-1].content, "## **Antwort**\n`SQLite`")
|
||||||
|
|
||||||
|
def test_keyboard_interrupt_drops_partial_answer_and_returns_to_prompt(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||||
|
service = ChatService(store, _InterruptingProvider())
|
||||||
|
inputs = iter(["Frage", "/exit"])
|
||||||
|
output: list[str] = []
|
||||||
|
streamed: list[str] = []
|
||||||
|
|
||||||
|
result = run_chat(
|
||||||
|
service,
|
||||||
|
input_fn=lambda _prompt: next(inputs),
|
||||||
|
output=output.append,
|
||||||
|
stream_output=streamed.append,
|
||||||
|
)
|
||||||
|
session = service.list_sessions()[0]
|
||||||
|
messages = service.load_session(session.id).messages
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(messages, [])
|
||||||
|
self.assertTrue(any("nicht gespeichert" in line for line in output))
|
||||||
|
self.assertEqual(output[-1], "Chat beendet.")
|
||||||
|
|
||||||
def test_provider_privacy_and_status_commands_are_sanitized(self) -> None:
|
def test_provider_privacy_and_status_commands_are_sanitized(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||||
@@ -108,6 +177,76 @@ class CliTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("Gemini-Schlüssel vorhanden: ja", output)
|
self.assertIn("Gemini-Schlüssel vorhanden: ja", output)
|
||||||
|
|
||||||
|
def test_numbered_sessions_load_rename_and_search(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = SQLiteSessionStore(Path(directory) / "sessions.sqlite3")
|
||||||
|
service = ChatService(store, _FakeProvider())
|
||||||
|
first = service.new_session()
|
||||||
|
service.send(first.id, "Erste Frage")
|
||||||
|
second = service.new_session()
|
||||||
|
service.send(second.id, "Zweite Frage")
|
||||||
|
shown_before = service.list_sessions()
|
||||||
|
target = shown_before[1]
|
||||||
|
inputs = iter(
|
||||||
|
[
|
||||||
|
"/sessions",
|
||||||
|
"/load 2",
|
||||||
|
"/rename Gefundene Sitzung",
|
||||||
|
"/search Gefundene",
|
||||||
|
"/exit",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
output: list[str] = []
|
||||||
|
|
||||||
|
result = run_chat(
|
||||||
|
service,
|
||||||
|
session_id=first.id,
|
||||||
|
input_fn=lambda _prompt: next(inputs),
|
||||||
|
output=output.append,
|
||||||
|
)
|
||||||
|
renamed_title = store.get_session(target.id).title
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertTrue(any(line.startswith("1. ") and "Frage" in line for line in output))
|
||||||
|
self.assertIn(f"Sitzung geladen: {target.id}", output)
|
||||||
|
self.assertEqual(renamed_title, "Gefundene Sitzung")
|
||||||
|
self.assertTrue(any("Gefundene Sitzung" in line for line in output))
|
||||||
|
|
||||||
|
def test_load_accepts_unique_prefix_and_rejects_ambiguous_prefix(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "sessions.sqlite3"
|
||||||
|
store = SQLiteSessionStore(database)
|
||||||
|
service = ChatService(store, _FakeProvider())
|
||||||
|
active = service.new_session()
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions
|
||||||
|
(id, created_at, updated_at, provider, model, title, last_provider)
|
||||||
|
VALUES (?, '2026-01-01', '2026-01-01',
|
||||||
|
'ollama', 'test-model', ?, 'ollama')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
("shared-111", "Eins"),
|
||||||
|
("shared-222", "Zwei"),
|
||||||
|
("unique-333", "Drei"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
inputs = iter(["/load unique", "/load shared", "/exit"])
|
||||||
|
output: list[str] = []
|
||||||
|
|
||||||
|
result = run_chat(
|
||||||
|
service,
|
||||||
|
session_id=active.id,
|
||||||
|
input_fn=lambda _prompt: next(inputs),
|
||||||
|
output=output.append,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertIn("Sitzung geladen: unique-333", output)
|
||||||
|
self.assertTrue(any("nicht eindeutig" in line for line in output))
|
||||||
|
|
||||||
def test_gemini_configuration_defaults_to_no(self) -> None:
|
def test_gemini_configuration_defaults_to_no(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
path = Path(directory) / "javis.toml"
|
path = Path(directory) / "javis.toml"
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
@@ -23,6 +23,7 @@ class _FakeModels:
|
|||||||
def __init__(self, result: object) -> None:
|
def __init__(self, result: object) -> None:
|
||||||
self.result = result
|
self.result = result
|
||||||
self.call: dict[str, Any] | None = None
|
self.call: dict[str, Any] | None = None
|
||||||
|
self.stream_call: dict[str, Any] | None = None
|
||||||
|
|
||||||
def generate_content(self, **kwargs: object) -> object:
|
def generate_content(self, **kwargs: object) -> object:
|
||||||
self.call = kwargs
|
self.call = kwargs
|
||||||
@@ -30,6 +31,14 @@ class _FakeModels:
|
|||||||
raise self.result
|
raise self.result
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
def generate_content_stream(self, **kwargs: object):
|
||||||
|
self.stream_call = kwargs
|
||||||
|
if isinstance(self.result, BaseException):
|
||||||
|
raise self.result
|
||||||
|
if isinstance(self.result, list):
|
||||||
|
return iter(self.result)
|
||||||
|
return iter((self.result,))
|
||||||
|
|
||||||
|
|
||||||
class _FakeClient:
|
class _FakeClient:
|
||||||
def __init__(self, result: object) -> None:
|
def __init__(self, result: object) -> None:
|
||||||
@@ -56,6 +65,23 @@ def _response(text: str = "Cloud-Antwort") -> SimpleNamespace:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_response(
|
||||||
|
visible_text: str,
|
||||||
|
*,
|
||||||
|
thought_text: str | None = None,
|
||||||
|
with_usage: bool = False,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
parts = []
|
||||||
|
if thought_text:
|
||||||
|
parts.append(SimpleNamespace(text=thought_text, thought=True))
|
||||||
|
parts.append(SimpleNamespace(text=visible_text, thought=False))
|
||||||
|
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=7) if with_usage else None
|
||||||
|
return SimpleNamespace(
|
||||||
|
candidates=[SimpleNamespace(content=SimpleNamespace(parts=parts))],
|
||||||
|
usage_metadata=usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GeminiProviderTests(unittest.TestCase):
|
class GeminiProviderTests(unittest.TestCase):
|
||||||
def _provider(
|
def _provider(
|
||||||
self, result: object, **overrides: object
|
self, result: object, **overrides: object
|
||||||
@@ -99,6 +125,39 @@ class GeminiProviderTests(unittest.TestCase):
|
|||||||
self.assertEqual([content.role for content in call["contents"]], ["user", "model", "user"])
|
self.assertEqual([content.role for content in call["contents"]], ["user", "model", "user"])
|
||||||
self.assertEqual(call["config"].max_output_tokens, 1024)
|
self.assertEqual(call["config"].max_output_tokens, 1024)
|
||||||
self.assertIsNone(call["config"].tools)
|
self.assertIsNone(call["config"].tools)
|
||||||
|
self.assertFalse(call["config"].thinking_config.include_thoughts)
|
||||||
|
|
||||||
|
def test_streaming_yields_only_visible_text_and_tracks_usage(self) -> None:
|
||||||
|
provider, factory = self._provider(
|
||||||
|
[
|
||||||
|
_stream_response("Cloud ", thought_text="verstecktes Denken"),
|
||||||
|
_stream_response("Antwort", with_usage=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = list(provider.stream_chat([ChatMessage("user", "Was ist SQLite?")]))
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Cloud ", "Antwort"])
|
||||||
|
self.assertNotIn("verstecktes Denken", "".join(chunks))
|
||||||
|
self.assertEqual(provider.last_usage.input_tokens, 12)
|
||||||
|
self.assertEqual(provider.last_usage.output_tokens, 7)
|
||||||
|
self.assertEqual(
|
||||||
|
factory.client.models.stream_call["model"],
|
||||||
|
"gemini-3.6-flash",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_streaming_error_is_sanitized(self) -> None:
|
||||||
|
provider, _ = self._provider(
|
||||||
|
errors.ClientError(
|
||||||
|
429,
|
||||||
|
{"message": "quota test-key-not-a-real-secret"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ProviderRateLimitError) as raised:
|
||||||
|
list(provider.stream_chat([ChatMessage("user", "Was ist Python?")]))
|
||||||
|
|
||||||
|
self.assertNotIn("test-key-not-a-real-secret", str(raised.exception))
|
||||||
|
|
||||||
def test_missing_key_stops_before_client_creation(self) -> None:
|
def test_missing_key_stops_before_client_creation(self) -> None:
|
||||||
factory = _ClientFactory(_response())
|
factory = _ClientFactory(_response())
|
||||||
|
|||||||
@@ -4,22 +4,39 @@ import unittest
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from javis.providers.base import ChatMessage
|
from javis.providers.base import ChatMessage, ResponseAbortedError
|
||||||
from javis.providers.ollama import OllamaProvider
|
from javis.providers.ollama import OllamaProvider
|
||||||
|
|
||||||
|
|
||||||
class _OllamaHandler(BaseHTTPRequestHandler):
|
class _OllamaHandler(BaseHTTPRequestHandler):
|
||||||
request_payload: ClassVar[dict[str, object]] = {}
|
request_payload: ClassVar[dict[str, object]] = {}
|
||||||
|
abort_stream: ClassVar[bool] = False
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
length = int(self.headers["Content-Length"])
|
length = int(self.headers["Content-Length"])
|
||||||
type(self).request_payload = json.loads(self.rfile.read(length))
|
type(self).request_payload = json.loads(self.rfile.read(length))
|
||||||
body = json.dumps(
|
if type(self).request_payload["stream"]:
|
||||||
{
|
chunks = [
|
||||||
"message": {"role": "assistant", "content": "Lokale Antwort"},
|
{
|
||||||
"done": True,
|
"message": {"role": "assistant", "content": "Lokale "},
|
||||||
}
|
"done": False,
|
||||||
).encode()
|
}
|
||||||
|
]
|
||||||
|
if not type(self).abort_stream:
|
||||||
|
chunks.append(
|
||||||
|
{
|
||||||
|
"message": {"role": "assistant", "content": "Antwort"},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
body = b"".join(json.dumps(chunk).encode() + b"\n" for chunk in chunks)
|
||||||
|
else:
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"message": {"role": "assistant", "content": "Lokale Antwort"},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
@@ -39,6 +56,9 @@ class _OllamaHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class OllamaProviderTests(unittest.TestCase):
|
class OllamaProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
_OllamaHandler.abort_stream = False
|
||||||
|
|
||||||
def test_provider_uses_local_chat_endpoint(self) -> None:
|
def test_provider_uses_local_chat_endpoint(self) -> None:
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
|
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
@@ -62,6 +82,44 @@ class OllamaProviderTests(unittest.TestCase):
|
|||||||
self.assertTrue(status.reachable)
|
self.assertTrue(status.reachable)
|
||||||
self.assertTrue(status.model_available)
|
self.assertTrue(status.model_available)
|
||||||
|
|
||||||
|
def test_provider_streams_visible_text_without_thinking(self) -> None:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
provider = OllamaProvider(
|
||||||
|
"test-model",
|
||||||
|
f"http://127.0.0.1:{server.server_port}",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
chunks = list(provider.stream_chat([ChatMessage("user", "Hallo")]))
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Lokale ", "Antwort"])
|
||||||
|
self.assertTrue(_OllamaHandler.request_payload["stream"])
|
||||||
|
self.assertFalse(_OllamaHandler.request_payload["think"])
|
||||||
|
|
||||||
|
def test_provider_rejects_incomplete_stream(self) -> None:
|
||||||
|
_OllamaHandler.abort_stream = True
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), _OllamaHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
provider = OllamaProvider(
|
||||||
|
"test-model",
|
||||||
|
f"http://127.0.0.1:{server.server_port}",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
with self.assertRaises(ResponseAbortedError):
|
||||||
|
list(provider.stream_chat([ChatMessage("user", "Hallo")]))
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ class PrivacyRouterTests(unittest.TestCase):
|
|||||||
CloudPolicy.NEVER,
|
CloudPolicy.NEVER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_medical_questions_are_always_never(self) -> None:
|
||||||
|
for question in (
|
||||||
|
"Wenn ich blute, sollte ich dann zum Arzt?",
|
||||||
|
"Welche Symptome hat eine Grippe?",
|
||||||
|
"Wie nehme ich dieses Medikament ein?",
|
||||||
|
"Was bedeutet diese Diagnose?",
|
||||||
|
"Wie sollte eine Verletzung behandelt werden?",
|
||||||
|
):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
self.assertEqual(
|
||||||
|
self.router.classify(question).policy,
|
||||||
|
CloudPolicy.NEVER,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_technical_terms_cannot_override_medical_never(self) -> None:
|
||||||
|
decision = self.router.classify(
|
||||||
|
"Wie speichere ich meine Symptome und Diagnose mit Python in SQLite?"
|
||||||
|
)
|
||||||
|
self.assertEqual(decision.policy, CloudPolicy.NEVER)
|
||||||
|
|
||||||
def test_unknown_content_is_conservatively_ask(self) -> None:
|
def test_unknown_content_is_conservatively_ask(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.router.classify("Erkläre das bitte genauer.").policy,
|
self.router.classify("Erkläre das bitte genauer.").policy,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -16,6 +16,7 @@ from javis.providers.base import (
|
|||||||
ProviderRateLimitError,
|
ProviderRateLimitError,
|
||||||
ProviderTimeoutError,
|
ProviderTimeoutError,
|
||||||
ProviderUsage,
|
ProviderUsage,
|
||||||
|
ResponseAbortedError,
|
||||||
)
|
)
|
||||||
from javis.security.privacy import CloudPolicy, PrivacyRouter
|
from javis.security.privacy import CloudPolicy, PrivacyRouter
|
||||||
|
|
||||||
@@ -28,11 +29,15 @@ class _RecordingProvider:
|
|||||||
*,
|
*,
|
||||||
answer: str = "Antwort",
|
answer: str = "Antwort",
|
||||||
error: Exception | None = None,
|
error: Exception | None = None,
|
||||||
|
stream_chunks: tuple[str, ...] | None = None,
|
||||||
|
stream_error: Exception | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.model = model
|
self.model = model
|
||||||
self.answer = answer
|
self.answer = answer
|
||||||
self.error = error
|
self.error = error
|
||||||
|
self.stream_chunks = stream_chunks
|
||||||
|
self.stream_error = stream_error
|
||||||
self.calls: list[list[ChatMessage]] = []
|
self.calls: list[list[ChatMessage]] = []
|
||||||
self.last_usage = ProviderUsage(11, 5)
|
self.last_usage = ProviderUsage(11, 5)
|
||||||
|
|
||||||
@@ -42,6 +47,17 @@ class _RecordingProvider:
|
|||||||
raise self.error
|
raise self.error
|
||||||
return self.answer
|
return self.answer
|
||||||
|
|
||||||
|
def stream_chat(self, messages: list[ChatMessage]):
|
||||||
|
self.calls.append(messages)
|
||||||
|
if self.stream_chunks is None:
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
yield self.answer
|
||||||
|
else:
|
||||||
|
yield from self.stream_chunks
|
||||||
|
if self.stream_error:
|
||||||
|
raise self.stream_error
|
||||||
|
|
||||||
|
|
||||||
class HybridProviderTests(unittest.TestCase):
|
class HybridProviderTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -131,6 +147,23 @@ class HybridProviderTests(unittest.TestCase):
|
|||||||
self.assertTrue(router.last_route.fallback)
|
self.assertTrue(router.last_route.fallback)
|
||||||
self.assertIn("lokale Antwort", self.notices[-1])
|
self.assertIn("lokale Antwort", self.notices[-1])
|
||||||
|
|
||||||
|
def test_medical_never_routes_locally_before_cloud_construction(self) -> None:
|
||||||
|
constructed = False
|
||||||
|
|
||||||
|
def cloud_factory() -> _RecordingProvider:
|
||||||
|
nonlocal constructed
|
||||||
|
constructed = True
|
||||||
|
return self.cloud
|
||||||
|
|
||||||
|
router = self._router(cloud_provider_factory=cloud_factory, mode="gemini")
|
||||||
|
|
||||||
|
answer = router.chat([ChatMessage("user", "Wenn ich blute, sollte ich dann zum Arzt?")])
|
||||||
|
|
||||||
|
self.assertEqual(answer, "Lokal")
|
||||||
|
self.assertFalse(constructed)
|
||||||
|
self.assertFalse(self.approvals)
|
||||||
|
self.assertEqual(router.last_route.privacy_policy, CloudPolicy.NEVER)
|
||||||
|
|
||||||
def test_ask_defaults_to_local_when_approval_is_denied(self) -> None:
|
def test_ask_defaults_to_local_when_approval_is_denied(self) -> None:
|
||||||
router = self._router()
|
router = self._router()
|
||||||
|
|
||||||
@@ -149,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()
|
||||||
@@ -266,6 +312,64 @@ class HybridProviderTests(unittest.TestCase):
|
|||||||
self.assertFalse(router.last_route.cloud_suppressed_by_local_mode)
|
self.assertFalse(router.last_route.cloud_suppressed_by_local_mode)
|
||||||
self.assertTrue(router.last_route.technical_fallback)
|
self.assertTrue(router.last_route.technical_fallback)
|
||||||
|
|
||||||
|
def test_allowed_cloud_response_streams_visible_chunks(self) -> None:
|
||||||
|
self.cloud.stream_chunks = ("Cloud ", "Stream")
|
||||||
|
router = self._router()
|
||||||
|
|
||||||
|
chunks = list(router.stream_chat([ChatMessage("user", "Wie funktioniert SQLite?")]))
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Cloud ", "Stream"])
|
||||||
|
self.assertEqual(router.last_route.provider, "gemini")
|
||||||
|
self.assertFalse(router.last_route.fallback)
|
||||||
|
|
||||||
|
def test_cloud_stream_error_before_output_falls_back_locally(self) -> None:
|
||||||
|
self.cloud.stream_chunks = ()
|
||||||
|
self.cloud.stream_error = CloudNetworkError("offline")
|
||||||
|
self.local.stream_chunks = ("Lokaler ", "Ersatz")
|
||||||
|
router = self._router()
|
||||||
|
|
||||||
|
chunks = list(router.stream_chat([ChatMessage("user", "Wie funktioniert Python?")]))
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Lokaler ", "Ersatz"])
|
||||||
|
self.assertTrue(router.last_route.fallback)
|
||||||
|
self.assertTrue(router.last_route.technical_fallback)
|
||||||
|
self.assertIn("lokale Antwort", self.notices[-1])
|
||||||
|
|
||||||
|
def test_cloud_stream_error_after_output_never_appends_local_answer(self) -> None:
|
||||||
|
self.cloud.stream_chunks = ("Teilantwort",)
|
||||||
|
self.cloud.stream_error = CloudNetworkError("offline")
|
||||||
|
self.local.stream_chunks = ("Lokaler Ersatz",)
|
||||||
|
router = self._router()
|
||||||
|
stream = router.stream_chat([ChatMessage("user", "Wie funktioniert Python?")])
|
||||||
|
|
||||||
|
self.assertEqual(next(stream), "Teilantwort")
|
||||||
|
with self.assertRaises(ResponseAbortedError):
|
||||||
|
next(stream)
|
||||||
|
|
||||||
|
self.assertFalse(self.local.calls)
|
||||||
|
self.assertFalse(router.last_route.fallback)
|
||||||
|
self.assertTrue(router.last_route.technical_fallback)
|
||||||
|
|
||||||
|
def test_medical_never_streams_locally_before_cloud_construction(self) -> None:
|
||||||
|
constructed = False
|
||||||
|
self.local.stream_chunks = ("Lokal",)
|
||||||
|
|
||||||
|
def cloud_factory() -> _RecordingProvider:
|
||||||
|
nonlocal constructed
|
||||||
|
constructed = True
|
||||||
|
return self.cloud
|
||||||
|
|
||||||
|
router = self._router(cloud_provider_factory=cloud_factory, mode="gemini")
|
||||||
|
|
||||||
|
chunks = list(
|
||||||
|
router.stream_chat([ChatMessage("user", "Welche Diagnose passt zu meinen Schmerzen?")])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(chunks, ["Lokal"])
|
||||||
|
self.assertFalse(constructed)
|
||||||
|
self.assertFalse(self.approvals)
|
||||||
|
self.assertEqual(router.last_route.privacy_policy, CloudPolicy.NEVER)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from javis.memory.sqlite_store import SessionNotFoundError, SQLiteSessionStore
|
from javis.memory.sqlite_store import (
|
||||||
|
AmbiguousSessionReferenceError,
|
||||||
|
SessionNotFoundError,
|
||||||
|
SQLiteSessionStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SQLiteSessionStoreTests(unittest.TestCase):
|
class SQLiteSessionStoreTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||||
database = Path(self.temporary_directory.name) / "sessions.sqlite3"
|
self.database = Path(self.temporary_directory.name) / "sessions.sqlite3"
|
||||||
self.store = SQLiteSessionStore(database)
|
self.store = SQLiteSessionStore(self.database)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.temporary_directory.cleanup()
|
self.temporary_directory.cleanup()
|
||||||
@@ -21,6 +26,8 @@ class SQLiteSessionStoreTests(unittest.TestCase):
|
|||||||
self.assertEqual(loaded.id, created.id)
|
self.assertEqual(loaded.id, created.id)
|
||||||
self.assertEqual(loaded.provider, "ollama")
|
self.assertEqual(loaded.provider, "ollama")
|
||||||
self.assertEqual(loaded.model, "test-model")
|
self.assertEqual(loaded.model, "test-model")
|
||||||
|
self.assertEqual(loaded.title, "Neue Sitzung")
|
||||||
|
self.assertEqual(loaded.last_provider, "ollama")
|
||||||
self.assertEqual(self.store.list_sessions()[0].id, created.id)
|
self.assertEqual(self.store.list_sessions()[0].id, created.id)
|
||||||
|
|
||||||
def test_messages_keep_exchange_order(self) -> None:
|
def test_messages_keep_exchange_order(self) -> None:
|
||||||
@@ -53,6 +60,112 @@ class SQLiteSessionStoreTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.store.get_messages(session.id), [])
|
self.assertEqual(self.store.get_messages(session.id), [])
|
||||||
self.assertEqual(self.store.get_session(session.id).message_count, 0)
|
self.assertEqual(self.store.get_session(session.id).message_count, 0)
|
||||||
|
|
||||||
|
def test_first_exchange_sets_title_and_last_provider_atomically(self) -> None:
|
||||||
|
session = self.store.create_session("hybrid", "auto")
|
||||||
|
|
||||||
|
self.store.append_exchange(
|
||||||
|
session.id,
|
||||||
|
"Frage",
|
||||||
|
"Antwort",
|
||||||
|
title_if_first="Kurzer Titel",
|
||||||
|
last_provider="gemini",
|
||||||
|
)
|
||||||
|
self.store.append_exchange(
|
||||||
|
session.id,
|
||||||
|
"Noch eine Frage",
|
||||||
|
"Noch eine Antwort",
|
||||||
|
title_if_first="Darf Titel nicht ersetzen",
|
||||||
|
last_provider="ollama",
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded = self.store.get_session(session.id)
|
||||||
|
self.assertEqual(loaded.title, "Kurzer Titel")
|
||||||
|
self.assertEqual(loaded.last_provider, "ollama")
|
||||||
|
|
||||||
|
def test_old_database_is_migrated_idempotently_without_data_loss(self) -> None:
|
||||||
|
database = Path(self.temporary_directory.name) / "legacy.sqlite3"
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (session_id, position)
|
||||||
|
);
|
||||||
|
INSERT INTO sessions VALUES
|
||||||
|
('legacy', '2026-01-01', '2026-01-02', 'ollama', 'old-model');
|
||||||
|
INSERT INTO messages
|
||||||
|
(session_id, position, role, content, created_at)
|
||||||
|
VALUES ('legacy', 0, 'user', 'Alte Nachricht', '2026-01-02');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
migrated = SQLiteSessionStore(database)
|
||||||
|
migrated_again = SQLiteSessionStore(database)
|
||||||
|
|
||||||
|
session = migrated_again.get_session("legacy")
|
||||||
|
self.assertEqual(session.title, "Neue Sitzung")
|
||||||
|
self.assertEqual(session.last_provider, "ollama")
|
||||||
|
self.assertEqual(
|
||||||
|
[message.content for message in migrated.get_messages("legacy")],
|
||||||
|
["Alte Nachricht"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rename_and_search_only_titles_and_user_messages(self) -> None:
|
||||||
|
first = self.store.create_session("ollama", "test-model")
|
||||||
|
second = self.store.create_session("ollama", "test-model")
|
||||||
|
self.store.rename_session(first.id, "SQLite Hilfe")
|
||||||
|
self.store.append_exchange(first.id, "Transaktion erklären", "Privates Lösungswort")
|
||||||
|
self.store.append_exchange(second.id, "Andere Frage", "Nur SQLite in Antwort")
|
||||||
|
|
||||||
|
self.assertEqual(self.store.get_session(first.id).title, "SQLite Hilfe")
|
||||||
|
self.assertEqual(
|
||||||
|
[session.id for session in self.store.search_sessions("Transaktion")],
|
||||||
|
[first.id],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.store.search_sessions("Lösungswort"), [])
|
||||||
|
self.assertEqual(self.store.search_sessions("SQLite"), [self.store.get_session(first.id)])
|
||||||
|
|
||||||
|
def test_search_treats_sql_wildcards_and_injection_as_plain_text(self) -> None:
|
||||||
|
session = self.store.create_session("ollama", "test-model")
|
||||||
|
self.store.rename_session(session.id, "100% SQLite_Name")
|
||||||
|
|
||||||
|
self.assertEqual(self.store.search_sessions("%")[0].id, session.id)
|
||||||
|
self.assertEqual(self.store.search_sessions("_")[0].id, session.id)
|
||||||
|
self.assertEqual(self.store.search_sessions("' OR 1=1 --"), [])
|
||||||
|
|
||||||
|
def test_unique_and_ambiguous_session_prefixes(self) -> None:
|
||||||
|
with sqlite3.connect(self.database) as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions
|
||||||
|
(id, created_at, updated_at, provider, model, title, last_provider)
|
||||||
|
VALUES (?, '2026-01-01', '2026-01-01', 'ollama', 'model', ?, 'ollama')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
("abc111", "Eins"),
|
||||||
|
("abc222", "Zwei"),
|
||||||
|
("unique333", "Drei"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(self.store.resolve_unique_prefix("unique").id, "unique333")
|
||||||
|
with self.assertRaises(AmbiguousSessionReferenceError):
|
||||||
|
self.store.resolve_unique_prefix("abc")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user