Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97b6ea1b3e | |||
| 94ee0455a2 | |||
| 0bf6d49432 | |||
| 493cba36a2 | |||
| a68827fb38 | |||
| 11ca316e4e | |||
| be1d2e950a | |||
| 199297a3a1 | |||
| e99bf0b032 | |||
| 41999c2304 | |||
| 095c1e2d70 | |||
| 0145179aca | |||
| c2475ffef6 | |||
| 98982fea2f | |||
| 356f8b3171 | |||
| b4115bb345 | |||
| 02cac99ef9 | |||
| 2940ce0075 | |||
| d78b668e31 | |||
| a9115699db | |||
| f2bfd4bbc6 | |||
| b182ef5ed5 | |||
| 9818dc1867 | |||
| 543ad3c46d | |||
| 408d20a087 | |||
| 0756baa2a0 | |||
| 27c9b1af96 | |||
| 70f4ff480e | |||
| c23daf14e3 | |||
| ebfde4cd1f | |||
| 5d3e3e5e8c | |||
| 0d69e211cb | |||
| 4ea13afe60 | |||
| d12bfd0302 | |||
| 8d5991f364 | |||
| 7d16a0f3e5 | |||
| 0a859f637b | |||
| 8c1476c2ca | |||
| 7d8c411f5c | |||
| fef2a32c50 | |||
| e7fd918559 | |||
| bb3c7957aa | |||
| 89cafa6251 | |||
| 1ea7ab5ab1 | |||
| 15f95ed196 | |||
| 210ce62ffe | |||
| 298b2202a1 | |||
| 845a8b0020 | |||
| 0540c49c66 | |||
| add303970b | |||
| fb71048dfd | |||
| aaaf118cb7 | |||
| 5e1cb2d26a | |||
| 8359500476 | |||
| 1a72f27861 | |||
| 32302a841e | |||
| 474e2c6c50 | |||
| 3e0cfef63c | |||
| b94626787b | |||
| ad87c807de | |||
| 72277098af | |||
| 80d2fe3e93 | |||
| b5ca3cd371 | |||
| d939fc4ac3 | |||
| 13e87fb083 | |||
| 30c1dd7473 | |||
| 9ed9c99b0e | |||
| 1ea614c26b | |||
| acaa9fc3f2 | |||
| 0887674497 | |||
| f5243b1abb | |||
| eb5c178139 | |||
| 31b0bfaac1 | |||
| 1d3c45fdda | |||
| 84a59d7b4f | |||
| 8ad3e39453 | |||
| afa96b1d44 | |||
| 0407c5bc3c | |||
| 2d348aeec7 | |||
| 7e53dcfed3 | |||
| 33d5be781f | |||
| 785f5d0805 | |||
| fac87474ec | |||
| 8227266aea | |||
| 5d24e01d4b | |||
| 4fe72cc4a8 | |||
| eeeb1d43f5 | |||
| 0044e222db | |||
| 048d231b60 | |||
| 2bac9c26ca | |||
| c758727345 | |||
| cb0e879118 | |||
| ce6f5b551e | |||
| b6a68b7658 | |||
| 03edee8881 | |||
| 7093ebaf0b | |||
| b4923bc221 | |||
| 7a66752655 | |||
| b510ccd93a | |||
| bbd51406a9 | |||
| 2cd436f6e9 | |||
| 22adc91c1e | |||
| 61cf8e3bcc | |||
| 3e38f1dad3 | |||
| 635944299e | |||
| b2ac013765 | |||
| 93db6a3156 | |||
| 579a466402 | |||
| 5133f0bc2d | |||
| a476a4b734 | |||
| 11b205ddaf | |||
| 71c60ade8a | |||
| bf3dc635d9 | |||
| 8ca899aaf5 | |||
| 15facf48eb | |||
| 71fc90fcb8 | |||
| 856701fb6f | |||
| 6037b62612 | |||
| 8f88cb0030 | |||
| c224562423 | |||
| 5c07aef526 | |||
| d54d37061f | |||
| a6afec0e11 | |||
| 205112021b | |||
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd | |||
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed |
@@ -16,11 +16,21 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
|
|||||||
# Alle muessen den gleichen Host, Port und Token nutzen.
|
# Alle muessen den gleichen Host, Port und Token nutzen.
|
||||||
|
|
||||||
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
|
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
|
||||||
|
# WICHTIG: muss oeffentlich aufloesbar sein (DNS), nicht nur intern.
|
||||||
|
# Wird auch fuer OAuth-Callback-URLs verwendet — Spotify/Google/etc.
|
||||||
|
# redirecten Stefan im Browser an https://{RVS_HOST}/oauth/callback/{service}.
|
||||||
RVS_HOST=rvs.example.de
|
RVS_HOST=rvs.example.de
|
||||||
|
|
||||||
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
|
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
|
||||||
RVS_PORT=443
|
RVS_PORT=443
|
||||||
|
|
||||||
|
# Oeffentlich erreichbarer TLS-Port — was Browser/Provider von aussen sehen.
|
||||||
|
# Meist identisch mit RVS_PORT, kann aber abweichen wenn ein TLS-Terminator
|
||||||
|
# (Caddy/Nginx) davor steht der z.B. 444 auf intern 3000 mappt. Wird fuer
|
||||||
|
# die OAuth-Callback-URL benutzt; muss zu dem Eintrag im Provider-Dashboard
|
||||||
|
# passen. Leer/ungesetzt = RVS_PORT wird verwendet.
|
||||||
|
RVS_PORT_PUBLIC=
|
||||||
|
|
||||||
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
|
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
|
||||||
RVS_TLS=true
|
RVS_TLS=true
|
||||||
|
|
||||||
@@ -35,6 +45,21 @@ RVS_TLS_FALLBACK=true
|
|||||||
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
|
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
|
||||||
RVS_TOKEN=
|
RVS_TOKEN=
|
||||||
|
|
||||||
|
# ── Brain-Timeouts ───────────────────────────────
|
||||||
|
# Brain redet via HTTP mit dem Proxy-Container. Da der Proxy non-streaming
|
||||||
|
# antwortet (Response kommt erst nach subprocess-close), kann ein Brain-Call
|
||||||
|
# bei langen Agent-Sessions (Pentests, Multi-Step-Tasks) >1h dauern.
|
||||||
|
# PROXY_TIMEOUT_SEC ist der httpx-Read-Timeout im Brain — wir setzen ihn
|
||||||
|
# bewusst hoch (24h), der Proxy hat einen eigenen Idle-Watchdog
|
||||||
|
# (ARIA_IDLE_TIMEOUT_MS in der proxy-Logik, default 20min Inaktivitaet)
|
||||||
|
# der den Subprocess killt wenn wirklich was haengt.
|
||||||
|
# Connect/Write/Pool bleiben klein damit toter Proxy in 10s erkannt wird.
|
||||||
|
PROXY_TIMEOUT_SEC=86400
|
||||||
|
# Diese drei sind defensive Defaults — aendern nur wenn netzwerk-bedingt noetig.
|
||||||
|
# PROXY_CONNECT_TIMEOUT_SEC=10
|
||||||
|
# PROXY_WRITE_TIMEOUT_SEC=30
|
||||||
|
# PROXY_POOL_TIMEOUT_SEC=10
|
||||||
|
|
||||||
# ── Gitea — Release-Verwaltung ───────────────────
|
# ── Gitea — Release-Verwaltung ───────────────────
|
||||||
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
|
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
|
||||||
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
|
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ aria-data/brain/qdrant/
|
|||||||
# Diagnostic-State (aktive Session etc.)
|
# Diagnostic-State (aktive Session etc.)
|
||||||
aria-data/config/diag-state/
|
aria-data/config/diag-state/
|
||||||
|
|
||||||
|
# ── Shared Volume (Bind-Mount statt Docker-managed) ──
|
||||||
|
# Enthaelt User-Uploads, Voice-Cloning-Samples, OAuth-Tokens,
|
||||||
|
# chat_backup.jsonl, Memory-Attachments, runtime-state. Hunderte MB,
|
||||||
|
# enthaelt PRIVATE Daten. Backup via Diagnostic, nicht via Git.
|
||||||
|
aria-shared/
|
||||||
|
|
||||||
# ── Node / npm ──────────────────────────────────
|
# ── Node / npm ──────────────────────────────────
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ Die Diagnostic-UI hat sechs Top-Tabs:
|
|||||||
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
||||||
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
|
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
|
||||||
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
|
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
|
||||||
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), near(lat, lon, m) als Condition-Funktion
|
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), GPS-Funktionen `near() / entered_near() / left_near()` für unterschiedliche Geofencing-Modi
|
||||||
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
||||||
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
||||||
|
|
||||||
@@ -219,11 +219,15 @@ Der Proxy-Container (`node:22-alpine`) installiert bei jedem Start:
|
|||||||
Danach wird der Proxy gepatcht:
|
Danach wird der Proxy gepatcht:
|
||||||
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
|
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
|
||||||
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
|
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
|
||||||
3. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
|
3. **CLI-Timeout** (sed): `DEFAULT_TIMEOUT 300000 → 1200000` (5 → 20 Min) im subprocess-manager. Multi-Tool-Workflows mit echtem Bash + curl + DB-Inserts brauchen oft 8–15 Min; 5 Min war chronisch zu kurz
|
||||||
|
4. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
|
||||||
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
|
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
|
||||||
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
|
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
|
||||||
|
- `routes.js` hookt die `assistant`-Events des Subprozesses und feuert pro `tool_use`-Block (Bash, Read, Edit, Grep, …) einen HTTP-POST an die Bridge (`/internal/agent-activity`). Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic → der Gedanken-Stream zeigt live mit was ARIA gerade tut. Fire-and-forget, fail-open — Brain-Call bricht nicht ab wenn die Bridge mal nicht da ist.
|
||||||
|
|
||||||
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren.
|
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren. Der Tool-Hook im `routes.js` macht zusaetzlich das interne Claude-Code-Werkzeug-Geschehen fuer den User sichtbar.
|
||||||
|
|
||||||
|
**Brain ↔ Bridge ist async**: `_handle_rvs_message` ruft `send_to_core` als `asyncio.create_task` statt `await` — sonst blockierte der WS-recv-Loop bis zu 20 Min und der RVS-Server (mobil.hacker-net.de) droppte die Bridge nach ~4 Min Idle-Timeout. Brain laeuft jetzt im Hintergrund-Task, RVS-Verbindung bleibt waehrend ARIA arbeitet aktiv.
|
||||||
|
|
||||||
**Wichtige Umgebungsvariablen im Proxy:**
|
**Wichtige Umgebungsvariablen im Proxy:**
|
||||||
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
|
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
|
||||||
@@ -297,6 +301,16 @@ aria-brain → Antwort → Bridge → RVS → App
|
|||||||
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
|
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
|
||||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
|
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
|
||||||
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
- **Modi**: Normal, Nicht stoeren, Fluestern, Hangar, Gaming
|
||||||
|
- **3-Schichten Hang-Schutz** (gegen tote NAT-Verbindungen + asyncio-Limbo):
|
||||||
|
(1) TCP-Keepalive auf dem RVS-Socket (30s idle / 10s probe / 3 retries —
|
||||||
|
tote Connections in ~1 min erkannt statt nach 2h Linux-Default),
|
||||||
|
(2) Asyncio-Heartbeat-Watchdog (eigene Coroutine, killt WS-Connection
|
||||||
|
wenn `_last_heartbeat_ok` > 60s stale ist — Schutz gegen
|
||||||
|
`ws.ping()`-Limbo bei halb-toten Verbindungen),
|
||||||
|
(3) File-Based Liveness Thread (separater OS-Thread, immun gegen asyncio-
|
||||||
|
Hangs, `os._exit(1)` nach 180s Staleness → Docker restart_policy
|
||||||
|
uebernimmt). Plus: TLS-Fallback klebt nicht mehr — bei Reconnect
|
||||||
|
wird wieder primary wss:// versucht.
|
||||||
|
|
||||||
### Betriebsmodi
|
### Betriebsmodi
|
||||||
|
|
||||||
@@ -310,18 +324,130 @@ aria-brain → Antwort → Bridge → RVS → App
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Skills — Architektur
|
||||||
|
|
||||||
|
Skills sind ARIAs wiederverwendbare Faehigkeiten. Jeder Skill ist ein
|
||||||
|
Python-Programm in seinem eigenen `local-venv`. ARIA legt sie selbst via
|
||||||
|
`skill_create` an, fixt Bugs mit `skill_update`, rollt zur Not zurueck
|
||||||
|
mit `skill_rollback`.
|
||||||
|
|
||||||
|
### Skill-Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/skills/<name>/
|
||||||
|
skill.json # Manifest (Metadata + config_schema + version_history)
|
||||||
|
run.py # Entry-Point (Python via venv-python)
|
||||||
|
requirements.txt # pip-Pakete fuer die venv
|
||||||
|
README.md # Beschreibung
|
||||||
|
venv/ # automatisch erzeugt
|
||||||
|
logs/<ts>.json # Run-Logs (append-only)
|
||||||
|
versions/v_<ts>/ # archivierte Vorgaengerstaende (vor jedem update_skill)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drei-Stufen-Daten-Modell
|
||||||
|
|
||||||
|
Skills muessen **niemals** Credentials hardcoden. Drei saubere Wege:
|
||||||
|
|
||||||
|
1. **OAuth2-Tokens** (Spotify, Google, GitHub, Reddit, …): Brain haelt
|
||||||
|
Client-Credentials und macht den Auth-Flow. Skill ruft
|
||||||
|
`GET {BRAIN_INTERNAL_URL}/oauth/<service>/token` und bekommt einen
|
||||||
|
frischen access_token (Auto-Refresh < 60 s Restzeit).
|
||||||
|
2. **Statische Werte** (API-Keys, User-IDs, Default-Geraete): Skill
|
||||||
|
deklariert ein `config_schema` in `skill.json`, Stefan setzt die
|
||||||
|
Werte in Diagnostic / App, Skill bekommt sie zur Laufzeit als
|
||||||
|
`CFG_<UPPER_NAME>` ENV.
|
||||||
|
3. **Brain-Daten** (Memories, Skills-Liste, Standort etc.): jeder Skill
|
||||||
|
kann gegen `BRAIN_INTERNAL_URL` Endpoints wie `/memory/search`,
|
||||||
|
`/memory/pinned`, `/skills/list` rufen — z.B. ein Wetter-Skill kann
|
||||||
|
Stefans Standort aus Memories holen statt ihn als Arg zu erwarten.
|
||||||
|
|
||||||
|
### Versionierung mit Rollback
|
||||||
|
|
||||||
|
`update_skill` archiviert den aktuellen Stand vor jeder strukturellen
|
||||||
|
Aenderung (entry_code, readme, pip_packages, config_schema, args) nach
|
||||||
|
`versions/v_<ts>/`. ARIA-Tools `skill_list_versions` + `skill_rollback`
|
||||||
|
(+ HTTP `/skills/{name}/versions` + `/rollback`) erlauben Wiederherstellung.
|
||||||
|
Vor jedem Rollback wird der aktuelle Stand als „safety-snapshot" gesichert
|
||||||
|
— der Rollback selbst ist also nicht destruktiv.
|
||||||
|
|
||||||
|
UI sowohl in Diagnostic (Skill-Detail → 📦 Versionen) als auch in der App
|
||||||
|
(SkillBrowser → Detail-Modal).
|
||||||
|
|
||||||
|
### Anti-Skill-Friedhof
|
||||||
|
|
||||||
|
ARIA hat frueher gerne 9 Spotify-Skills mit Suffixen `-v2`, `-aria`,
|
||||||
|
`-ctl`, `-fixed` gebaut statt einen sauberen zu pflegen.
|
||||||
|
`skills.create_skill()` rejected jetzt hart:
|
||||||
|
|
||||||
|
- Versions-Suffixe (`-v\d+`, `_v\d+`, `-new`, `-fixed`, `-old`,
|
||||||
|
`-alt`, `-copy`, `-final`, `-clean`)
|
||||||
|
- Prefix-Kollisionen (`spotify` existiert → `spotify-aria` rejected)
|
||||||
|
|
||||||
|
Plus die Skill-Regeln (siehe naechster Abschnitt) erinnern ARIA bei jedem
|
||||||
|
Chat-Turn an die richtigen Patterns.
|
||||||
|
|
||||||
|
### Skill-Regeln (seed_rules)
|
||||||
|
|
||||||
|
`aria-brain/seed_rules.py` enthaelt 20 `type=rule, pinned=true,
|
||||||
|
source=seed`-Memories, die bei jedem Brain-Start idempotent in die
|
||||||
|
Vector-DB geschrieben werden (`migration_key`-basiert). Sie tauchen in
|
||||||
|
jedem Chat-Turn im Hot-Memory-Block auf:
|
||||||
|
|
||||||
|
- **list-before-create** — IMMER `skill_list` vor `skill_create`
|
||||||
|
- **no-version-suffix** — keine `-v2`/`_v3`-Namen, Versionsverwaltung ist intern
|
||||||
|
- **update-not-recreate** — defekten Skill mit `skill_update` fixen, nicht neu bauen
|
||||||
|
- **no-hardcoded-credentials** — OAuth-Tokens via `oauth_get_token`, keine client_secrets im Code
|
||||||
|
- **config-schema-for-settings** — statische Werte via `config_schema`, nicht hardcoded
|
||||||
|
- **brain-internal-url** — `BRAIN_INTERNAL_URL` Endpoints inkl. `/oauth/<s>/token`, `/memory/search`, `/memory/pinned`, `/skills/list`
|
||||||
|
- **oauth-reauth-reflex** — bei 401: ZUERST `oauth_get_token` (Auto-Refresh), nur bei dessen Fehler `oauth_authorize`
|
||||||
|
- **no-skill-drift** — kein Drift vom Skill zu Ad-hoc-Bash-Befehlen. Skill kaputt? `skill_logs` + `skill_update`. Niemals nur SAGEN „ich baue dir einen Skill", wenn `skill_create` nicht wirklich gefeuert wird
|
||||||
|
- **runtime-topology** (architektur) — ARIA laeuft als `claude`-CLI-Subprocess IM aria-proxy Container (alpine — kein python3/jq), NICHT im aria-brain. `/data/skills/` und `BRAIN_INTERNAL_URL` existieren dort nicht. Brain-Resources via Brain-Tools (`oauth_get_token`, `memory_search`, `run_<skill>` …), nicht via Bash. SSH zur VM-Host via `ssh aria@host` (Key liegt im Proxy)
|
||||||
|
- **scaffold-reflex** — ARIA entscheidet selbst ob ein wiederkehrender Bash-Pattern Skill-würdig ist (parametrisierbar + wiederkehrend + nicht-exploratory). Im Zweifel fragt sie Stefan. **Kein Auto-Scaffold, kein Tracking, keine Pflege** — Skills werden bewusst angelegt, nicht magisch. Pentest/Audit/Recherche bleibt ad-hoc Bash, auch bei 100× derselbe Host.
|
||||||
|
- **external-api-auth-strategy** — OAuth2 → `oauth_get_token`, sonst `config_schema`, NIEMALS hardcoden
|
||||||
|
|
||||||
|
### Skill-Scaffold (Templates)
|
||||||
|
|
||||||
|
Statt jedes Mal einen kompletten Skill aus dem Nichts zu generieren,
|
||||||
|
ruft ARIA `skill_scaffold(name, template, params)` — Brain expandiert
|
||||||
|
ein passendes Skelett. Massiv niedrigere Hürde gegen Skill-Drift.
|
||||||
|
|
||||||
|
Drei mitgelieferte Templates (`aria-brain/skill_templates.py`):
|
||||||
|
|
||||||
|
| Template | Wofür | params |
|
||||||
|
|---|---|---|
|
||||||
|
| `oauth-api` | Spotify, GitHub, Reddit, Google, Discord — Token aus Brain mit Auto-Refresh | `{service: "spotify", base_url?}` |
|
||||||
|
| `apikey-api` | OpenWeather, OpenAI, Twilio — statischer Key in `config_schema` → `CFG_<NAME>` ENV | `{api_name, key_env, auth_header?, auth_prefix?, base_url}` |
|
||||||
|
| `file-process` | PDF/Bild/JSON-Wandler — Input aus `/shared/uploads/`, Output zurueck. `process()`-Stub, danach `skill_update` mit echtem Code | `{output_ext}` |
|
||||||
|
|
||||||
|
HTTP: `POST /skills/scaffold` + `GET /skills/templates` (Liste mit Param-Doku).
|
||||||
|
Nach Scaffold optional `skill_update` falls Custom-Logik gebraucht wird.
|
||||||
|
|
||||||
|
Im Gegensatz zu `aria-data/brain-import/` (User-Saatgut, gitignored,
|
||||||
|
manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
|
||||||
|
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
|
||||||
|
Brain neu starten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Diagnostic — Selbstcheck-UI und Einstellungen
|
## Diagnostic — Selbstcheck-UI und Einstellungen
|
||||||
|
|
||||||
Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
|
|
||||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
|
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
||||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
||||||
|
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
|
||||||
|
- `entered_near(lat, lon, r)` — EINMAL beim Eintritt (Übergang außen→innen). Use-Case: Blitzer-Warner mit r=2000 → 2 km Vorwarnung, oder Ankunfts-Erinnerung mit r=100
|
||||||
|
- `left_near(lat, lon, r)` — EINMAL beim Verlassen (Übergang innen→außen). Use-Case: „Hast du am Parkplatz X was vergessen?"
|
||||||
|
|
||||||
|
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
||||||
|
|
||||||
|
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
|
||||||
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
||||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, **FLUX Bildgenerierung** (Default-Modell + Raw/Switch-Keywords + HF-Token), **OAuth-Apps** (Spotify Default, alle anderen Provider per ARIA on-demand oder "+ Custom"-Button mit auth_url/token_url/scopes) mit client_id+client_secret pro Service + One-Click-Autorisieren + Service-Loeschen, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||||
|
|
||||||
### Was zusaetzlich noch drin steckt
|
### Was zusaetzlich noch drin steckt
|
||||||
|
|
||||||
@@ -331,7 +457,11 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
|
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
|
||||||
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
||||||
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
|
||||||
- **SSH Terminal**: direkter SSH-Zugang zu aria-wohnung
|
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
|
||||||
|
- **Debug-API ohne SSH** (Diagnostic-Server, Port 3001):
|
||||||
|
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
|
||||||
|
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
|
||||||
|
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat vier Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, `oauth_revoke`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -355,15 +485,24 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||||
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
|
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
|
||||||
|
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
|
||||||
|
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
|
||||||
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
||||||
|
- **💭 Gedanken-Stream**: chronologisches Log was ARIA intern macht — gefuettert aus `agent_activity`-Events (denkt / 🔧 Tool-Name / schreibt / ✓ fertig). Live-Update waehrend Brain arbeitet: pro Tool-Call (Bash, Read, Edit, Grep, …) erscheint sofort ein Eintrag, durchgereicht vom claude-max-api-proxy via `proxy-patches/routes.js`-Hook. Lange Pausen zwischen Denk-Phasen werden als Trennlinie mit Minuten-Hint sichtbar. App: Icon in der Statusleiste oeffnet ein Bottom-Sheet, persistiert in AsyncStorage (capped 500). Diagnostic: identische Funktion als zentrales Modal im Chat-Test-Header
|
||||||
|
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
|
||||||
|
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
|
||||||
|
- **App-Crash-Reporting**: ungefangene JS-Errors + React-Render-Fehler landen automatisch in `/shared/logs/app.log` via RVS — kein ADB noetig, Logs holen via `tools/fetch-app-logs.sh` oder Diagnostic GET `/api/app-log`. ErrorBoundary verhindert White-Screen, zeigt stattdessen Error-Box im Modal mit Stack-Trace + Schliessen-Button
|
||||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||||
|
- **Bubble-Aktionen** (Long-Press oder ⎘-Icon): oeffnet ein Aktions-Menu mit "📋 Ganzen Text teilen" (System-Share-Sheet → Zwischenablage / WhatsApp / etc.) plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option. Plus native Text-Markierung via `selectable` ist weiter da
|
||||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
||||||
|
- **Settings-Sektionen "🛠️ Skills" und "🔑 OAuth-Apps"** (unterwegs konfigurieren ohne Diagnostic): Skills-Browser mit Run + Live-stdout/stderr + Logs der letzten 20 Runs + Loeschen; OAuth-Apps mit client_id/secret-Eingabe + "Autorisieren ↗" (oeffnet System-Browser, redirect zur RVS-Callback-Seite, Status-Refresh nach 8s) + "+ Custom"-Modal um eigene Provider mit auth_url/token_url/scopes anzulegen
|
||||||
|
- **Voice-Speed persistent**: App-Setting wird in `voice_config.json` als `xttsSpeed` persistiert. Greift jetzt auch bei Diagnostic-Chats / Trigger-Replies / nach Bridge-Restart — nicht mehr nur waehrend der App-Chat-Sitzung
|
||||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||||
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
||||||
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
||||||
- QR-Code Scanner fuer Token-Pairing
|
- QR-Code Scanner fuer Token-Pairing
|
||||||
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
||||||
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
|
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
|
||||||
@@ -580,16 +719,27 @@ tar -czf aria-backup-$(date +%Y%m%d).tar.gz aria-data/
|
|||||||
|
|
||||||
## RVS — Rendezvous-Server
|
## RVS — Rendezvous-Server
|
||||||
|
|
||||||
Laeuft im Rechenzentrum. WebSocket Relay + Auto-Update Server.
|
Laeuft im Rechenzentrum. WebSocket Relay + OAuth-Callback HTTP-Server.
|
||||||
Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
|
Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rvs
|
cd rvs
|
||||||
|
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
|
||||||
|
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
|
||||||
|
|
||||||
|
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
|
||||||
|
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
|
||||||
|
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
|
||||||
|
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
|
||||||
|
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
|
||||||
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
||||||
- Heartbeat + tote Verbindungen aufraeumen
|
- Heartbeat + tote Verbindungen aufraeumen
|
||||||
|
|
||||||
@@ -602,6 +752,11 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
|
|||||||
|
|
||||||
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
|
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
|
||||||
|
|
||||||
|
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
|
||||||
|
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
|
||||||
|
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
|
||||||
|
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
|
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services)
|
||||||
@@ -867,15 +1022,29 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
||||||
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
|
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
|
||||||
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
||||||
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, drei GPS-Funktionen `near()` / `entered_near()` / `left_near()` für unterschiedliche Geofencing-Modi, Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Tick-Frequenz 8s + event-getriebene Auswertung bei jedem `location_update` (statt 30s-Polling) damit auch Auto-Vorbeifahrten bei 100+ km/h durch kleine Radien zuverlässig erwischt werden. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten. Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
||||||
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
||||||
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
||||||
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
||||||
|
- [x] **Memory-Editor in der App** (5 Etappen): Notizen-Inbox-Button neben der Lupe oeffnet ein Modal mit allen Spezial-Bubbles aus dem aktuellen Chat plus dem vollen DB-Browser. Tap auf eine Memory → Detail-Modal mit Anhang-Vorschau, Stift-Icon wechselt in Edit-Mode (Felder editieren + Anhaenge hoch-/runterladen + loeschen). Identischer Editor unter Settings → 🧠 Gedaechtnis. Bubble-Header dynamic je nach Aktion (created/updated/deleted). RVS-Brain-Proxy als Fundament (`brain_request`/`brain_response`) damit die App beliebige Brain-HTTP-Endpoints adressieren kann. `memory_search` + `memory_update` als ARIA-Tools damit sie aktiv die DB pruefen und Eintraege patchen kann statt zu fragmentieren.
|
||||||
|
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary + global JS-Error-Handler + Promise-Rejection-Tracker schicken Crashes als `app_log`-Event durch RVS. Bridge sammelt in `/shared/logs/app.log`, Diagnostic GET `/api/app-log`. `tools/fetch-app-logs.sh` holt die Logs auf die Dev-Maschine (gitignored `.aria-debug/`). Damit kann Stefan unterwegs ohne ADB debuggen — der erste Bug (URLSearchParams in Hermes) wurde so in 5 Minuten gefunden.
|
||||||
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
||||||
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
||||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||||
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
|
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
|
||||||
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
|
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
|
||||||
|
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der Gamebox (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
|
||||||
|
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
|
||||||
|
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
|
||||||
|
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
|
||||||
|
- [x] **Skill-Architektur P0-P4**:
|
||||||
|
- `seed_rules` (9 pinned rule-Memories) werden bei jedem Brain-Boot idempotent in die DB geschrieben (`source=seed`, `migration_key`-basiert). Decken Skill-Friedhof, OAuth-Auth-Strategie, no-skill-drift, BRAIN_INTERNAL_URL ab
|
||||||
|
- Anti-Friedhof-Check in `create_skill`: rejected Versions-Suffixe + Prefix-Kollisionen hart
|
||||||
|
- Neuer Brain-HTTP-Endpoint `/oauth/<service>/token` + `BRAIN_INTERNAL_URL` ENV-Var fuer Skills — Skill ruft Brain fuer frischen Token statt client_secret hardzucoden
|
||||||
|
- `config_schema` in skill.json + zentrales `/shared/config/skill_configs.json` + `CFG_<NAME>` ENV beim Run + `skill_set_config` Brain-Tool + UI in Diagnostic & App (TextInput / Switch / password-Felder mit `***SET***`-Masking)
|
||||||
|
- Versionierung: jeder `skill_update` archiviert vorherigen Stand nach `versions/v_<ts>/` (ohne venv/logs). `skill_list_versions` + `skill_rollback` Brain-Tools (mit Safety-Snapshot + auto venv-Rebuild). UI mit Rollback-Button in Diagnostic & App
|
||||||
|
- [x] **Bridge-Hang-Schutz + Voice-Speed persistent**: 3-Schichten-Watchdog (TCP-Keepalive + Asyncio-Watchdog + File-Based Liveness mit Self-Kill), TLS-Fallback klebt nicht mehr beim Reconnect. `xttsSpeed` jetzt im voice_config.json persistiert — greift auch bei Diagnostic-Chats und nach Bridge-Restart
|
||||||
|
- [x] **Bubble-Aktionen in der App**: Long-Press oder ⎘-Icon auf einer Chat-Bubble → Aktions-Menu mit "📋 Ganzen Text teilen" plus pro extrahierte URL/E-Mail/Telefonnummer eine eigene Teilen-Option (System-Share-Sheet → Zwischenablage / Apps / Browser)
|
||||||
|
|
||||||
### Phase 2 — ARIA wird produktiv
|
### Phase 2 — ARIA wird produktiv
|
||||||
|
|
||||||
|
|||||||
+65
-1
@@ -6,7 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { StatusBar, StyleSheet } from 'react-native';
|
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ import ChatScreen from './src/screens/ChatScreen';
|
|||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
|
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||||
|
import gpsTrackingService from './src/services/gpsTracking';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -61,8 +64,69 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
initConnection();
|
initConnection();
|
||||||
|
|
||||||
|
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
|
||||||
|
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
|
||||||
|
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
|
||||||
|
// durch weil Android nach ~30s die JS-Engine pausiert.
|
||||||
|
//
|
||||||
|
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
|
||||||
|
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
|
||||||
|
const initBackground = async () => {
|
||||||
|
const setting = await AsyncStorage.getItem('aria_background_mode');
|
||||||
|
if (setting === 'false') {
|
||||||
|
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Permission fuer die persistente Notification
|
||||||
|
if (Platform.OS === 'android' && Platform.Version >= 33) {
|
||||||
|
try {
|
||||||
|
await PermissionsAndroid.request(
|
||||||
|
'android.permission.POST_NOTIFICATIONS' as any,
|
||||||
|
{
|
||||||
|
title: 'Hintergrund-Modus',
|
||||||
|
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
|
||||||
|
buttonPositive: 'Erlauben',
|
||||||
|
buttonNegative: 'Spaeter',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await acquireBackgroundAudio('background');
|
||||||
|
console.log('[App] Hintergrund-Modus aktiv');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initBackground();
|
||||||
|
|
||||||
|
// GPS-Tracking-Status aus AsyncStorage wiederherstellen (war
|
||||||
|
// bisher nur an SettingsScreen-Mount gekoppelt; wenn Stefan
|
||||||
|
// direkt im Chat startete blieb GPS aus bis er Settings oeffnete).
|
||||||
|
gpsTrackingService.restoreFromStorage().catch((err) => {
|
||||||
|
console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AppState-Listener: nach Hintergrund-Rueckkehr aktiv die WS-
|
||||||
|
// Verbindung neu aufbauen. Hintergrund: Android kann den TCP-Socket
|
||||||
|
// im Background killen, JS-State zeigt aber noch OPEN → Stefan musste
|
||||||
|
// manuell in Settings auf "Verbinden" tippen, oft mehrfach. Mit dem
|
||||||
|
// force-Reconnect bei "active" greift das automatisch.
|
||||||
|
let lastAppState: AppStateStatus = AppState.currentState;
|
||||||
|
const appStateSub = AppState.addEventListener('change', (next) => {
|
||||||
|
const wasBg = lastAppState !== 'active';
|
||||||
|
lastAppState = next;
|
||||||
|
if (next === 'active' && wasBg) {
|
||||||
|
console.log('[App] Foreground-Resume — force-reconnect zum RVS');
|
||||||
|
try { rvs.connect(true); } catch (e: any) {
|
||||||
|
console.warn('[App] force-reconnect fehlgeschlagen:', e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Beim Beenden: Verbindung sauber trennen
|
// Beim Beenden: Verbindung sauber trennen
|
||||||
return () => {
|
return () => {
|
||||||
|
appStateSub.remove();
|
||||||
rvs.disconnect();
|
rvs.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10309
|
versionCode 10801
|
||||||
versionName "0.1.3.9"
|
versionName "0.1.8.1"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,26 @@
|
|||||||
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
|
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<!-- Background-Location ist OPT-IN (Settings → GPS auch im Hintergrund).
|
||||||
|
Muss vom User explizit in Android-Einstellungen auf "Immer erlauben"
|
||||||
|
gesetzt werden — kann nicht ueber den normalen Permission-Dialog
|
||||||
|
angefordert werden (Android 10+). Default: aus. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
||||||
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||||
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
||||||
Aufnahme im Gespraechsmodus). -->
|
Aufnahme im Gespraechsmodus). LOCATION wird nur aktiv wenn der
|
||||||
|
User Background-GPS in Settings einschaltet. -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze
|
||||||
|
arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord
|
||||||
|
laeuft weiter aber JS-Bridge frisst die DeviceEvents nicht mehr ->
|
||||||
|
Wake-Word wird erkannt aber callbacks feuern erst beim App-Resume. -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -52,6 +64,6 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".AriaPlaybackService"
|
android:name=".AriaPlaybackService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback|microphone" />
|
android:foregroundServiceType="mediaPlayback|microphone|location" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
@@ -32,15 +34,26 @@ class AriaPlaybackService : Service() {
|
|||||||
|
|
||||||
private var currentReason: String = ""
|
private var currentReason: String = ""
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK haelt die CPU wach solange der Foreground-Service
|
||||||
|
// aktiv ist. Damit bleibt die JS-Bridge im Doze ansprechbar und die
|
||||||
|
// gesamte Sprach-Pipeline (Wake → Aufnahme → POST → ARIA → TTS → wieder
|
||||||
|
// Wake) laeuft durchgehend im Hintergrund. Ein einziger Lock fuer den
|
||||||
|
// ganzen Foreground-Cycle, nicht pro Sub-Modul.
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ensureNotificationChannel()
|
ensureNotificationChannel()
|
||||||
|
acquireWakeLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
||||||
currentReason = reason
|
currentReason = reason
|
||||||
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
||||||
|
// Falls der Lock zwischendurch released wurde (z.B. nach onCreate-
|
||||||
|
// race oder OS-quirk), hier sicherheits-halber erneut anfordern.
|
||||||
|
acquireWakeLock()
|
||||||
try {
|
try {
|
||||||
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -53,10 +66,36 @@ class AriaPlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
releaseWakeLock()
|
||||||
Log.i(TAG, "Foreground-Service gestoppt")
|
Log.i(TAG, "Foreground-Service gestoppt")
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
if (wakeLock?.isHeld == true) return
|
||||||
|
try {
|
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"AriaCockpit:Pipeline").apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire(8 * 60 * 60 * 1000L) // 8h Sicherheits-Cap
|
||||||
|
}
|
||||||
|
Log.i(TAG, "WakeLock acquired (CPU bleibt wach im Hintergrund)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
try {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
if (wakeLock != null) Log.i(TAG, "WakeLock released")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun ensureNotificationChannel() {
|
private fun ensureNotificationChannel() {
|
||||||
|
|||||||
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sanfter Spotify-Resume-Nudge: kurz USAGE_MEDIA mit TRANSIENT
|
||||||
|
* requesten und sofort abandonen. Spotify bekommt das als
|
||||||
|
* Focus-Frei-Signal und resumed automatisch — aber weil TRANSIENT
|
||||||
|
* (nicht GAIN permanent), interpretiert Spotify das NICHT als
|
||||||
|
* "user stopped" was Auto-Resume verhindert haette.
|
||||||
|
*
|
||||||
|
* Hintergrund: ARIA spricht TTS via USAGE_ASSISTANT GAIN_TRANSIENT,
|
||||||
|
* Spotify pausiert. ARIA released. Spotify SOLLTE nach
|
||||||
|
* TRANSIENT-Loss + Abandon automatisch resumen, tut es aber bei
|
||||||
|
* manchen Versionen / Geraeten nicht zuverlaessig. Dieser Nudge
|
||||||
|
* triggert den Focus-Stack-Refresh ohne den Spotify-Auto-Stop-Bug
|
||||||
|
* der alten kickReleaseMedia mit GAIN permanent.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun nudgeMediaResume(promise: Promise) {
|
||||||
|
val am = audioManager()
|
||||||
|
if (am == null) {
|
||||||
|
promise.resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val attrs = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||||
|
.build()
|
||||||
|
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
val nudgeReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener(nudgeListener)
|
||||||
|
.build()
|
||||||
|
am.requestAudioFocus(nudgeReq)
|
||||||
|
Thread.sleep(100)
|
||||||
|
am.abandonAudioFocusRequest(nudgeReq)
|
||||||
|
} else {
|
||||||
|
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.requestAudioFocus(nudgeListener, AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
Thread.sleep(100)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(nudgeListener)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "nudgeMediaResume: USAGE_MEDIA TRANSIENT request+abandon (Spotify-Resume-Trigger)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "nudgeMediaResume failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
||||||
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
||||||
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
||||||
@@ -140,6 +192,10 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
*
|
*
|
||||||
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
||||||
* laesst den AudioFocusRequest haengen.
|
* laesst den AudioFocusRequest haengen.
|
||||||
|
*
|
||||||
|
* ⚠️ ACHTUNG: nutzt AUDIOFOCUS_GAIN (permanent), Spotify kann das als
|
||||||
|
* "user-action stopp" interpretieren und Auto-Resume verhindern.
|
||||||
|
* Fuer Spotify-Resume nach TTS lieber nudgeMediaResume() nehmen (sanfter).
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun kickReleaseMedia(promise: Promise) {
|
fun kickReleaseMedia(promise: Promise) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
add(ApkInstallerPackage())
|
add(ApkInstallerPackage())
|
||||||
add(AudioFocusPackage())
|
add(AudioFocusPackage())
|
||||||
add(PcmStreamPlayerPackage())
|
add(PcmStreamPlayerPackage())
|
||||||
|
add(PcmStreamRecorderPackage())
|
||||||
add(OpenWakeWordPackage())
|
add(OpenWakeWordPackage())
|
||||||
add(PhoneCallPackage())
|
add(PhoneCallPackage())
|
||||||
add(BackgroundAudioPackage())
|
add(BackgroundAudioPackage())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ai.onnxruntime.OnnxTensor
|
|||||||
import ai.onnxruntime.OrtEnvironment
|
import ai.onnxruntime.OrtEnvironment
|
||||||
import ai.onnxruntime.OrtSession
|
import ai.onnxruntime.OrtSession
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
@@ -11,6 +12,7 @@ import android.media.MediaRecorder
|
|||||||
import android.media.audiofx.AcousticEchoCanceler
|
import android.media.audiofx.AcousticEchoCanceler
|
||||||
import android.media.audiofx.AutomaticGainControl
|
import android.media.audiofx.AutomaticGainControl
|
||||||
import android.media.audiofx.NoiseSuppressor
|
import android.media.audiofx.NoiseSuppressor
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.facebook.react.bridge.Promise
|
import com.facebook.react.bridge.Promise
|
||||||
@@ -80,6 +82,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private var ns: NoiseSuppressor? = null
|
private var ns: NoiseSuppressor? = null
|
||||||
private var agc: AutomaticGainControl? = null
|
private var agc: AutomaticGainControl? = null
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK damit die CPU bei aus-Display nicht in Doze geht und
|
||||||
|
// die JS-Bridge die WakeWordDetected-Events live verarbeitet (sonst
|
||||||
|
// queuen sich die Events nur und werden erst beim App-Foreground
|
||||||
|
// delivered — Stefan-Beobachtung: "Spotify pausiert, aber Gong/Aufnahme
|
||||||
|
// kommen erst wenn ich die App nach vorne hole").
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
// Inferenz-State
|
// Inferenz-State
|
||||||
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
||||||
private var melProcessedIdx: Int = 0
|
private var melProcessedIdx: Int = 0
|
||||||
@@ -198,6 +207,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
running.set(true)
|
running.set(true)
|
||||||
record.startRecording()
|
record.startRecording()
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
|
||||||
|
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
|
||||||
|
// 8h Cap als Sicherheit gegen forgotten-release.
|
||||||
|
try {
|
||||||
|
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"AriaCockpit:WakeWordRecord").apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire(8 * 60 * 60 * 1000L)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "WakeLock acquired")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
||||||
isDaemon = true
|
isDaemon = true
|
||||||
start()
|
start()
|
||||||
@@ -232,6 +256,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
audioRecord = null
|
audioRecord = null
|
||||||
releaseAudioEffects()
|
releaseAudioEffects()
|
||||||
|
releaseWakeLock()
|
||||||
Log.i(TAG, "Lauschen gestoppt")
|
Log.i(TAG, "Lauschen gestoppt")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
@@ -245,10 +270,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
audioRecord = null
|
audioRecord = null
|
||||||
releaseAudioEffects()
|
releaseAudioEffects()
|
||||||
|
releaseWakeLock()
|
||||||
disposeSessions()
|
disposeSessions()
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
try {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
if (wakeLock != null) Log.i(TAG, "WakeLock released")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun isAvailable(promise: Promise) {
|
fun isAvailable(promise: Promise) {
|
||||||
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
||||||
|
|||||||
@@ -361,6 +361,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
writerThread = null
|
writerThread = null
|
||||||
val t = track
|
val t = track
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
|
// pause() + flush() vor stop() — sonst spielt der Hardware-Buffer
|
||||||
|
// (200-500ms PCM-Samples) noch hörbar weiter, nachdem der User
|
||||||
|
// den Mute-Button gedrückt hat. Stefan-Bug-Report: "wenn ich auf
|
||||||
|
// den Mund halten Button klicke während ARIA redet stoppt sie nicht".
|
||||||
|
try { t.pause() } catch (_: Exception) {}
|
||||||
|
try { t.flush() } catch (_: Exception) {}
|
||||||
try { t.stop() } catch (_: Exception) {}
|
try { t.stop() } catch (_: Exception) {}
|
||||||
try { t.release() } catch (_: Exception) {}
|
try { t.release() } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.media.audiofx.AcousticEchoCanceler
|
||||||
|
import android.media.audiofx.AutomaticGainControl
|
||||||
|
import android.media.audiofx.NoiseSuppressor
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCM-Streaming-Recorder fuer die Streaming-Whisper-Bridge.
|
||||||
|
*
|
||||||
|
* Oeffnet AudioRecord (16 kHz mono s16le, VOICE_COMMUNICATION-Source mit
|
||||||
|
* automatischer AEC + NS) und feuert ~200ms-Chunks als base64-Event
|
||||||
|
* "PcmStreamChunk" an die JS-Bridge.
|
||||||
|
*
|
||||||
|
* audio.ts schickt die Chunks via RVS direkt an die whisper-bridge die
|
||||||
|
* dort einen ML-Endpointer laufen laesst — kein dB-VAD-Tuning mehr.
|
||||||
|
*
|
||||||
|
* Mic-Ownership: dieser Recorder DARF nicht gleichzeitig mit
|
||||||
|
* OpenWakeWord laufen — beide wollen AudioRecord vom MIC. Caller
|
||||||
|
* muss OpenWakeWord.stop() vor start() hier aufrufen und nach stop()
|
||||||
|
* hier wieder OpenWakeWord.start() — genau wie's audio.ts ohnehin
|
||||||
|
* macht.
|
||||||
|
*
|
||||||
|
* Events:
|
||||||
|
* "PcmStreamChunk" { pcm: base64-s16le, seq: N, ts: epochMs }
|
||||||
|
* "PcmStreamError" { error: string }
|
||||||
|
*/
|
||||||
|
class PcmStreamRecorderModule(reactContext: ReactApplicationContext) :
|
||||||
|
ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
override fun getName() = "PcmStreamRecorder"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PcmStreamRecorder"
|
||||||
|
private const val SAMPLE_RATE = 16000
|
||||||
|
// 200ms-Chunks: gross genug fuer wenig RVS-Overhead, klein genug damit
|
||||||
|
// der Endpointer im Whisper-Bridge granular sieht. 200ms ist auch das
|
||||||
|
// Whisper-VAD-Frame-Hop — passt also zu downstream.
|
||||||
|
private const val CHUNK_SAMPLES = 3200 // 200ms @ 16 kHz
|
||||||
|
private const val BYTES_PER_SAMPLE = 2 // s16
|
||||||
|
private const val CHUNK_BYTES = CHUNK_SAMPLES * BYTES_PER_SAMPLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private val running = AtomicBoolean(false)
|
||||||
|
private var captureThread: Thread? = null
|
||||||
|
|
||||||
|
private var aec: AcousticEchoCanceler? = null
|
||||||
|
private var ns: NoiseSuppressor? = null
|
||||||
|
private var agc: AutomaticGainControl? = null
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK damit der JS-Bridge-Loop weiterlaeuft auch wenn das
|
||||||
|
// Display aus ist — sonst sammeln sich zwar Chunks in der nativen Queue
|
||||||
|
// an, aber emit() landet nicht zeitnah in JS und der Whisper-Bridge
|
||||||
|
// bekommt die Audio-Chunks erst beim App-Foreground-Resume.
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
|
private var seq: Long = 0L
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun start(promise: Promise) {
|
||||||
|
if (running.get()) {
|
||||||
|
promise.resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val perm = ContextCompat.checkSelfPermission(
|
||||||
|
reactApplicationContext, Manifest.permission.RECORD_AUDIO
|
||||||
|
)
|
||||||
|
if (perm != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val minBuf = AudioRecord.getMinBufferSize(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
).coerceAtLeast(CHUNK_BYTES * 4) // 4x Chunk-Size als Sicherheit
|
||||||
|
|
||||||
|
val record = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
minBuf,
|
||||||
|
)
|
||||||
|
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
record.release()
|
||||||
|
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt? OpenWakeWord noch aktiv?)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audioRecord = record
|
||||||
|
|
||||||
|
// AEC/NS/AGC explizit anschalten — manche Geraete liefern's via
|
||||||
|
// VOICE_COMMUNICATION zwar mit, aber Belt-and-Suspenders.
|
||||||
|
try {
|
||||||
|
if (AcousticEchoCanceler.isAvailable()) {
|
||||||
|
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
|
||||||
|
try {
|
||||||
|
if (NoiseSuppressor.isAvailable()) {
|
||||||
|
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
|
||||||
|
try {
|
||||||
|
if (AutomaticGainControl.isAvailable()) {
|
||||||
|
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
|
||||||
|
|
||||||
|
seq = 0L
|
||||||
|
running.set(true)
|
||||||
|
record.startRecording()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"AriaCockpit:PcmStreamRecord").apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire(8 * 60 * 60 * 1000L) // 8h Cap
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
captureThread = Thread({ captureLoop() }, "PcmStreamRecorderCapture").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Recording gestartet (16kHz mono s16le, ${CHUNK_SAMPLES} samples/chunk)")
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "start fehlgeschlagen", e)
|
||||||
|
running.set(false)
|
||||||
|
audioRecord?.release()
|
||||||
|
audioRecord = null
|
||||||
|
releaseAudioEffects()
|
||||||
|
releaseWakeLock()
|
||||||
|
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun stop(promise: Promise) {
|
||||||
|
running.set(false)
|
||||||
|
try {
|
||||||
|
captureThread?.join(1500)
|
||||||
|
} catch (_: InterruptedException) {}
|
||||||
|
captureThread = null
|
||||||
|
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||||
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
|
audioRecord = null
|
||||||
|
releaseAudioEffects()
|
||||||
|
releaseWakeLock()
|
||||||
|
Log.i(TAG, "Recording gestoppt (seq=$seq Chunks gesendet)")
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun isRecording(promise: Promise) {
|
||||||
|
promise.resolve(running.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLoop() {
|
||||||
|
val buffer = ByteArray(CHUNK_BYTES)
|
||||||
|
val rec = audioRecord ?: return
|
||||||
|
try {
|
||||||
|
while (running.get()) {
|
||||||
|
var offset = 0
|
||||||
|
// Solange lesen bis ein voller 200ms-Chunk zusammen ist.
|
||||||
|
// AudioRecord.read kann weniger als angefordert liefern.
|
||||||
|
while (offset < CHUNK_BYTES && running.get()) {
|
||||||
|
val n = rec.read(buffer, offset, CHUNK_BYTES - offset)
|
||||||
|
if (n <= 0) {
|
||||||
|
if (!running.get()) break
|
||||||
|
// Fehlerzustand — kurze Pause, dann weiter probieren
|
||||||
|
Thread.sleep(5)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offset += n
|
||||||
|
}
|
||||||
|
if (offset < CHUNK_BYTES) break
|
||||||
|
|
||||||
|
val b64 = Base64.encodeToString(buffer, Base64.NO_WRAP)
|
||||||
|
val ts = System.currentTimeMillis()
|
||||||
|
val params = Arguments.createMap().apply {
|
||||||
|
putString("pcm", b64)
|
||||||
|
// putLong existiert nicht in WritableMap — putDouble fuer ts/seq.
|
||||||
|
putDouble("seq", seq.toDouble())
|
||||||
|
putDouble("ts", ts.toDouble())
|
||||||
|
}
|
||||||
|
reactApplicationContext
|
||||||
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
|
.emit("PcmStreamChunk", params)
|
||||||
|
seq++
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "captureLoop crashed", e)
|
||||||
|
try {
|
||||||
|
val err = Arguments.createMap().apply {
|
||||||
|
putString("error", e.message ?: "unknown")
|
||||||
|
}
|
||||||
|
reactApplicationContext
|
||||||
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
|
.emit("PcmStreamError", err)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseAudioEffects() {
|
||||||
|
try { aec?.release() } catch (_: Exception) {}
|
||||||
|
try { ns?.release() } catch (_: Exception) {}
|
||||||
|
try { agc?.release() } catch (_: Exception) {}
|
||||||
|
aec = null; ns = null; agc = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
try {
|
||||||
|
if (wakeLock?.isHeld == true) wakeLock?.release()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Damit RCTEventEmitter den Listener-Lifecycle nicht crasht
|
||||||
|
@ReactMethod fun addListener(eventName: String) {}
|
||||||
|
@ReactMethod fun removeListeners(count: Int) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.bridge.NativeModule
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
|
class PcmStreamRecorderPackage : ReactPackage {
|
||||||
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||||
|
return listOf(PcmStreamRecorderModule(reactContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.facebook.react.bridge.ReactApplicationContext
|
|||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import com.facebook.react.bridge.ReactMethod
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
||||||
@@ -35,6 +36,11 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
|||||||
private var legacyListener: PhoneStateListener? = null
|
private var legacyListener: PhoneStateListener? = null
|
||||||
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||||
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
||||||
|
// Eigener Single-Thread-Executor statt mainExecutor — der wird bei
|
||||||
|
// pausierter Activity verzoegert oder gar nicht abgearbeitet, der eigene
|
||||||
|
// Thread laeuft unabhaengig solange der App-Prozess lebt (was er ja tut,
|
||||||
|
// wir haben einen Foreground-Service der das garantiert).
|
||||||
|
private val callbackExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun start(promise: Promise) {
|
fun start(promise: Promise) {
|
||||||
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
|||||||
handleStateChange(state)
|
handleStateChange(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
|
tm.registerTelephonyCallback(callbackExecutor, cb)
|
||||||
modernCallback = cb
|
modernCallback = cb
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.3.9",
|
"version": "0.1.8.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
/**
|
||||||
|
* OAuth-Browser — Verwaltung der OAuth-Provider (Spotify + Custom) und ihrer
|
||||||
|
* Credentials. Eingesetzt von SettingsScreen → Sektion "OAuth-Apps".
|
||||||
|
*
|
||||||
|
* Pro Service:
|
||||||
|
* - Status (verbunden / konfiguriert / leer)
|
||||||
|
* - client_id + client_secret (Passwort-Toggle)
|
||||||
|
* - Bei Custom-Services: auch auth_url + token_url + scopes editierbar
|
||||||
|
* - "Autorisieren ↗" oeffnet die Provider-Auth-Seite im System-Browser
|
||||||
|
* - "Abmelden" + (bei Custom) "🗑 Service entfernen"
|
||||||
|
*
|
||||||
|
* Plus: "+ Custom-Service" oeffnet ein Modal fuer name/auth_url/token_url/scopes.
|
||||||
|
*
|
||||||
|
* Hinweis zu Credentials: client_id/client_secret laufen ueber HTTP zur
|
||||||
|
* Bridge, von dort zum Brain. Wenn die App via RVS verbunden ist, geht alles
|
||||||
|
* ueber TLS (wss://) — der Wert ist nie im Klartext im Netz unterwegs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Linking,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import brainApi, { OAuthServiceStatus, OAuthAppConfig } from '../services/brainApi';
|
||||||
|
|
||||||
|
const COL_OK = '#34C759';
|
||||||
|
const COL_PENDING = '#FFD60A';
|
||||||
|
const COL_OFF = '#666680';
|
||||||
|
const COL_ERR = '#FF6B6B';
|
||||||
|
|
||||||
|
function fmtExpiry(secs: number | null | undefined): string {
|
||||||
|
if (secs == null) return '';
|
||||||
|
if (secs <= 0) return 'abgelaufen';
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
if (secs < 3600) return `${Math.round(secs / 60)} min`;
|
||||||
|
if (secs < 86400) return `${Math.round(secs / 3600)} h`;
|
||||||
|
return `${Math.round(secs / 86400)} Tage`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MergedService extends OAuthServiceStatus {
|
||||||
|
app?: OAuthAppConfig;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OAuthBrowser: React.FC = () => {
|
||||||
|
const [services, setServices] = useState<MergedService[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [editService, setEditService] = useState<MergedService | null>(null);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
Promise.all([brainApi.listOAuthServices(), brainApi.getOAuthApps()])
|
||||||
|
.then(([statusRes, appsRes]) => {
|
||||||
|
const apps = appsRes.apps || {};
|
||||||
|
const defaults = new Set(appsRes.defaults || []);
|
||||||
|
const items: MergedService[] = (statusRes.services || []).map(s => ({
|
||||||
|
...s,
|
||||||
|
app: apps[s.service],
|
||||||
|
isDefault: defaults.has(s.service),
|
||||||
|
}));
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.authenticated !== b.authenticated) return a.authenticated ? -1 : 1;
|
||||||
|
if (a.configured !== b.configured) return a.configured ? -1 : 1;
|
||||||
|
return a.service.localeCompare(b.service);
|
||||||
|
});
|
||||||
|
setServices(items);
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: MergedService }) => {
|
||||||
|
let statusColor: string = COL_OFF;
|
||||||
|
let statusIcon = '⚫';
|
||||||
|
let statusText = 'nicht konfiguriert';
|
||||||
|
if (item.authenticated) {
|
||||||
|
statusColor = COL_OK; statusIcon = '✅';
|
||||||
|
statusText = `verbunden${item.expiresInSec != null ? ' · noch ' + fmtExpiry(item.expiresInSec) : ''}`;
|
||||||
|
} else if (item.configured) {
|
||||||
|
statusColor = COL_PENDING; statusIcon = '🟡';
|
||||||
|
statusText = 'konfiguriert, nicht autorisiert';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={s.row} onPress={() => setEditService(item)}>
|
||||||
|
<View style={{flex: 1, marginRight: 8}}>
|
||||||
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2}}>
|
||||||
|
<Text style={{color: '#E0E0F0', fontWeight: '600', fontSize: 14, textTransform: 'capitalize'}}>{item.service}</Text>
|
||||||
|
{!item.isDefault ? (
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 10}}>(custom)</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={{color: statusColor, fontSize: 12}}>{statusIcon} {statusText}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<View style={s.toolbar}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 11, flex: 1}}>
|
||||||
|
Verbinde ARIA mit externen Services (Spotify u.a.).
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||||
|
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||||
|
<Text style={{fontSize: 13, color: '#fff', fontWeight: '700'}}>+ Custom</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
{loading && services.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={services}
|
||||||
|
keyExtractor={s => s.service}
|
||||||
|
renderItem={renderItem}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||||
|
(keine OAuth-Services — frag ARIA: "verbinde mich mit X")
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{paddingBottom: 20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editService ? (
|
||||||
|
<OAuthEditModal
|
||||||
|
service={editService}
|
||||||
|
onClose={() => setEditService(null)}
|
||||||
|
onReload={() => { setEditService(null); load(); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showNew ? (
|
||||||
|
<OAuthCustomNewModal
|
||||||
|
onClose={() => setShowNew(false)}
|
||||||
|
onCreated={() => { setShowNew(false); load(); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Edit-Modal (Credentials + Authorize + Revoke + Delete) ──────────
|
||||||
|
|
||||||
|
interface EditProps {
|
||||||
|
service: MergedService;
|
||||||
|
onClose: () => void;
|
||||||
|
onReload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAuthEditModal: React.FC<EditProps> = ({ service: svc, onClose, onReload }) => {
|
||||||
|
const [clientId, setClientId] = useState(svc.app?.client_id || '');
|
||||||
|
const [clientSecret, setClientSecret] = useState('');
|
||||||
|
const [showSecret, setShowSecret] = useState(false);
|
||||||
|
const [authUrl, setAuthUrl] = useState(svc.app?.auth_url || '');
|
||||||
|
const [tokenUrl, setTokenUrl] = useState(svc.app?.token_url || '');
|
||||||
|
const [scopes, setScopes] = useState((svc.app?.scopes || []).join(' '));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!clientId.trim()) {
|
||||||
|
Alert.alert('Fehler', 'client_id darf nicht leer sein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const body: any = {
|
||||||
|
service: svc.service,
|
||||||
|
client_id: clientId.trim(),
|
||||||
|
};
|
||||||
|
if (clientSecret) body.client_secret = clientSecret;
|
||||||
|
if (authUrl.trim()) body.auth_url = authUrl.trim();
|
||||||
|
if (tokenUrl.trim()) body.token_url = tokenUrl.trim();
|
||||||
|
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
|
||||||
|
try {
|
||||||
|
await brainApi.saveOAuthApp(body);
|
||||||
|
onReload();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Speichern fehlgeschlagen', String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorize = async () => {
|
||||||
|
if (!svc.configured) {
|
||||||
|
Alert.alert('Erst Credentials eintragen', 'client_id und client_secret muessen vor dem Autorisieren gespeichert sein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await brainApi.authorizeOAuth(svc.service);
|
||||||
|
// Im System-Browser oeffnen — InAppBrowser wuerde z.T. von Providern blockiert
|
||||||
|
const ok = await Linking.canOpenURL(r.url);
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert('Browser nicht verfuegbar', 'Konnte die Auth-URL nicht oeffnen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Linking.openURL(r.url);
|
||||||
|
Alert.alert(
|
||||||
|
'Im Browser anmelden',
|
||||||
|
`Bitte stimme bei ${svc.service} zu. Nach dem Redirect zur Callback-Seite kannst du den Tab schliessen — ARIA bekommt das Token automatisch.\n\nDie Status-Anzeige in der App aktualisiert sich nach Refresh.`,
|
||||||
|
[{ text: 'OK', onPress: () => setTimeout(onReload, 8000) }],
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Authorize fehlgeschlagen', String(e?.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Abmelden?',
|
||||||
|
`Token fuer ${svc.service} entfernen. Du musst danach neu autorisieren.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Abmelden',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try { await brainApi.revokeOAuth(svc.service); onReload(); }
|
||||||
|
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeService = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Service komplett entfernen?',
|
||||||
|
`"${svc.service}" wird inkl. client_id/secret und Token geloescht.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loeschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try { await brainApi.deleteOAuthApp(svc.service); onReload(); }
|
||||||
|
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={s.modalTitle} numberOfLines={1}>{svc.service}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||||
|
{svc.authenticated ? (
|
||||||
|
<View style={[s.metaBox, {borderLeftWidth: 3, borderLeftColor: COL_OK, marginBottom: 12}]}>
|
||||||
|
<Text style={[s.meta, {color: COL_OK, fontWeight: '700'}]}>
|
||||||
|
✅ verbunden{svc.expiresInSec != null ? ` · Token noch ${fmtExpiry(svc.expiresInSec)}` : ''}
|
||||||
|
</Text>
|
||||||
|
{svc.hasRefresh ? <Text style={s.meta}>refresh_token vorhanden — auto-renew aktiv</Text>
|
||||||
|
: <Text style={[s.meta, {color: COL_ERR}]}>KEIN refresh_token — Token verfaellt komplett</Text>}
|
||||||
|
{svc.scope ? <Text style={s.meta}>scopes: {svc.scope}</Text> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text style={s.label}>client_id</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={clientId}
|
||||||
|
onChangeText={setClientId}
|
||||||
|
placeholder="aus dem Provider-Developer-Dashboard"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>
|
||||||
|
client_secret {svc.app?.has_client_secret ? '— gespeichert (leer = behalten)' : '— fehlt'}
|
||||||
|
</Text>
|
||||||
|
<View style={{flexDirection: 'row', gap: 6}}>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {flex: 1}]}
|
||||||
|
value={clientSecret}
|
||||||
|
onChangeText={setClientSecret}
|
||||||
|
placeholder={svc.app?.has_client_secret ? '(neuen eintragen oder leer lassen)' : 'aus dem Dashboard'}
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
secureTextEntry={!showSecret}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#1A1A2E', justifyContent: 'center'}]}
|
||||||
|
onPress={() => setShowSecret(v => !v)}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 14}}>{showSecret ? '🙈' : '👁'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan
|
||||||
|
nicht ausversehen die Spotify-URLs ueberschreibt. */}
|
||||||
|
{svc.isDefault ? (
|
||||||
|
<TouchableOpacity onPress={() => setShowAdvanced(v => !v)} style={{marginTop: 12}}>
|
||||||
|
<Text style={{color: '#666680', fontSize: 11, fontStyle: 'italic'}}>
|
||||||
|
{showAdvanced ? '▼' : '▶'} Default-URLs ueberschreiben (advanced)
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(!svc.isDefault || showAdvanced) ? (
|
||||||
|
<View style={{marginTop: 8}}>
|
||||||
|
<Text style={s.label}>auth_url</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={authUrl}
|
||||||
|
onChangeText={setAuthUrl}
|
||||||
|
placeholder="https://provider.com/oauth/authorize"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={s.label}>token_url</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={tokenUrl}
|
||||||
|
onChangeText={setTokenUrl}
|
||||||
|
placeholder="https://provider.com/oauth/token"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={s.label}>scopes (space-separated)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={scopes}
|
||||||
|
onChangeText={setScopes}
|
||||||
|
placeholder="read write user.email"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 16}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]}
|
||||||
|
onPress={save}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||||
|
{saving ? 'speichert...' : 'Speichern'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: svc.configured ? '#34C759' : '#1E1E2E', flex: 1}]}
|
||||||
|
onPress={authorize}
|
||||||
|
disabled={!svc.configured}
|
||||||
|
>
|
||||||
|
<Text style={{color: svc.configured ? '#fff' : '#555570', textAlign: 'center', fontWeight: '700'}}>
|
||||||
|
Autorisieren ↗
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{svc.authenticated ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 12}]}
|
||||||
|
onPress={revoke}
|
||||||
|
>
|
||||||
|
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>Abmelden (Token loeschen)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!svc.isDefault ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 8}]}
|
||||||
|
onPress={removeService}
|
||||||
|
>
|
||||||
|
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>🗑 Service komplett entfernen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{height: 30}} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Neuer Custom-Provider ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NewProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OAuthCustomNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [authUrl, setAuthUrl] = useState('https://');
|
||||||
|
const [tokenUrl, setTokenUrl] = useState('https://');
|
||||||
|
const [scopes, setScopes] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const svc = name.trim().toLowerCase();
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(svc)) {
|
||||||
|
Alert.alert('Ungueltiger Name', 'Erlaubt: a-z 0-9 _ -');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!authUrl.startsWith('http') || !tokenUrl.startsWith('http')) {
|
||||||
|
Alert.alert('Ungueltige URLs', 'auth_url und token_url muessen http(s):// sein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const body: any = { service: svc, auth_url: authUrl.trim(), token_url: tokenUrl.trim() };
|
||||||
|
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
|
||||||
|
await brainApi.saveOAuthApp(body);
|
||||||
|
onCreated();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={s.modalTitle}>Custom OAuth-Provider</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 12}}>
|
||||||
|
Trag die OAuth2-Endpunkte des Anbieters ein. client_id + client_secret
|
||||||
|
kommen anschliessend ins Edit-Formular. Die Callback-URL die du beim
|
||||||
|
Anbieter eintragen musst, zeigt dir der OAuth-Block im Brain-System-Prompt.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={s.label}>Service-Name (z.B. dropbox, discord)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="kurz, a-z 0-9 _ -"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>auth_url</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={authUrl}
|
||||||
|
onChangeText={setAuthUrl}
|
||||||
|
placeholder="https://provider.com/oauth/authorize"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>token_url</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={tokenUrl}
|
||||||
|
onChangeText={setTokenUrl}
|
||||||
|
placeholder="https://provider.com/oauth/token"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>scopes (space-separated, optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={scopes}
|
||||||
|
onChangeText={setScopes}
|
||||||
|
placeholder="read write user.email"
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 20}}>
|
||||||
|
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]} onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA', textAlign: 'center'}}>Abbrechen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]} onPress={create} disabled={creating}>
|
||||||
|
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||||
|
{creating ? '...' : 'Anlegen'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Styles ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
toolbar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
iconBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
err: {
|
||||||
|
color: '#FF6B6B',
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: '#E0E0F0',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#E0E0F0',
|
||||||
|
padding: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
metaBox: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 10,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default OAuthBrowser;
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
/**
|
||||||
|
* Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run,
|
||||||
|
* Logs und Loeschen.
|
||||||
|
*
|
||||||
|
* Eingesetzt von SettingsScreen → Sektion "Skills".
|
||||||
|
*
|
||||||
|
* Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT
|
||||||
|
* ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene
|
||||||
|
* (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
|
||||||
|
|
||||||
|
const COL_ACTIVE = '#34C759';
|
||||||
|
const COL_INACTIVE = '#555570';
|
||||||
|
const COL_ARIA = '#FFD60A';
|
||||||
|
const COL_STEFAN = '#0096FF';
|
||||||
|
|
||||||
|
function relTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!t) return '—';
|
||||||
|
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||||||
|
if (diffSec < 60) return `vor ${diffSec}s`;
|
||||||
|
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||||||
|
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||||||
|
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkillBrowser: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<Skill[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [detail, setDetail] = useState<Skill | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
brainApi.listSkills()
|
||||||
|
.then(s => {
|
||||||
|
s.sort((a, b) => {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
setItems(s);
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const visible = items.filter(s => {
|
||||||
|
if (filter === 'active') return s.active;
|
||||||
|
if (filter === 'inactive') return !s.active;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActive = (s: Skill) => {
|
||||||
|
brainApi.updateSkill(s.name, { active: !s.active })
|
||||||
|
.then(() => load())
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Skill }) => {
|
||||||
|
const isAria = (item.author || '').toLowerCase() === 'aria';
|
||||||
|
const authorColor = isAria ? COL_ARIA : COL_STEFAN;
|
||||||
|
const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
|
||||||
|
<View style={{flex: 1, marginRight: 8}}>
|
||||||
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||||
|
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
|
||||||
|
{item.setup_error ? (
|
||||||
|
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
|
||||||
|
⚠ Setup-Fehler: {item.setup_error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||||
|
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={item.active}
|
||||||
|
onValueChange={() => toggleActive(item)}
|
||||||
|
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||||
|
thumbColor="#E0E0F0"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<View style={s.toolbar}>
|
||||||
|
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[s.chip, filter === f && s.chipActive]}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
>
|
||||||
|
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||||
|
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||||
|
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={visible}
|
||||||
|
keyExtractor={s => s.name}
|
||||||
|
renderItem={renderItem}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||||
|
{items.length === 0
|
||||||
|
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
|
||||||
|
: '(keine Treffer für diesen Filter)'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{paddingBottom: 20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<SkillDetailModal
|
||||||
|
skill={detail}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
|
||||||
|
|
||||||
|
interface DetailProps {
|
||||||
|
skill: Skill;
|
||||||
|
onClose: () => void;
|
||||||
|
onReload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
|
||||||
|
const [argValues, setArgValues] = useState<Record<string, string>>({});
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [runResult, setRunResult] = useState<{
|
||||||
|
ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [logs, setLogs] = useState<any[] | null>(null);
|
||||||
|
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||||
|
|
||||||
|
// P3: Skill-Config (statische Werte je Skill, z.B. API-Keys)
|
||||||
|
const [cfgSchema, setCfgSchema] = useState<SkillConfigField[]>([]);
|
||||||
|
const [cfgValues, setCfgValues] = useState<Record<string, any>>({});
|
||||||
|
const [cfgDraft, setCfgDraft] = useState<Record<string, string>>({});
|
||||||
|
const [cfgSaving, setCfgSaving] = useState(false);
|
||||||
|
|
||||||
|
// P4: Versionen + Rollback
|
||||||
|
const [versions, setVersions] = useState<SkillVersion[]>([]);
|
||||||
|
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||||
|
|
||||||
|
const args = Array.isArray(skill.args) ? skill.args : [];
|
||||||
|
|
||||||
|
// Config + Versionen beim Mount laden
|
||||||
|
useEffect(() => {
|
||||||
|
brainApi.getSkillConfig(skill.name)
|
||||||
|
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); })
|
||||||
|
.catch(() => {});
|
||||||
|
setVersionsLoading(true);
|
||||||
|
brainApi.listSkillVersions(skill.name)
|
||||||
|
.then(setVersions)
|
||||||
|
.catch(() => setVersions([]))
|
||||||
|
.finally(() => setVersionsLoading(false));
|
||||||
|
}, [skill.name]);
|
||||||
|
|
||||||
|
const setArg = (name: string, value: string) =>
|
||||||
|
setArgValues(prev => ({ ...prev, [name]: value }));
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
setRunning(true); setRunResult(null);
|
||||||
|
const argsObj: Record<string, any> = {};
|
||||||
|
for (const a of args) {
|
||||||
|
if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') {
|
||||||
|
argsObj[a.name] = argValues[a.name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
brainApi.runSkill(skill.name, argsObj)
|
||||||
|
.then(r => setRunResult(r))
|
||||||
|
.catch(e => setRunResult({
|
||||||
|
ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0,
|
||||||
|
}))
|
||||||
|
.finally(() => setRunning(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogs = () => {
|
||||||
|
setLoadingLogs(true);
|
||||||
|
brainApi.getSkillLogs(skill.name, 20)
|
||||||
|
.then(setLogs)
|
||||||
|
.catch(e => Alert.alert('Logs-Fehler', String(e?.message || e)))
|
||||||
|
.finally(() => setLoadingLogs(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Skill loeschen?',
|
||||||
|
`"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loeschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
brainApi.deleteSkill(skill.name)
|
||||||
|
.then(() => { onReload(); onClose(); })
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = () => {
|
||||||
|
// secret-Felder die als '***SET***' angezeigt sind und vom User NICHT
|
||||||
|
// angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur
|
||||||
|
// explizit getippte Werte; alles andere uebernehmen wir aus cfgValues.
|
||||||
|
const next: Record<string, any> = { ...cfgValues };
|
||||||
|
for (const f of cfgSchema) {
|
||||||
|
const draft = cfgDraft[f.name];
|
||||||
|
const isSecret = f.secret || f.type === 'password';
|
||||||
|
if (draft === undefined) continue;
|
||||||
|
if (isSecret && draft === '') continue; // leer = unveraendert
|
||||||
|
if (draft === '') { delete next[f.name]; continue; }
|
||||||
|
if (f.type === 'number') {
|
||||||
|
const n = Number(draft); next[f.name] = isNaN(n) ? draft : n;
|
||||||
|
} else if (f.type === 'boolean') {
|
||||||
|
next[f.name] = draft === 'true' || draft === '1';
|
||||||
|
} else {
|
||||||
|
next[f.name] = draft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Maskierte Werte (***SET***) niemals zurueckschreiben
|
||||||
|
for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k];
|
||||||
|
setCfgSaving(true);
|
||||||
|
brainApi.setSkillConfig(skill.name, next)
|
||||||
|
.then(() => {
|
||||||
|
// frisch laden um neuen masked-State zu zeigen
|
||||||
|
return brainApi.getSkillConfig(skill.name);
|
||||||
|
})
|
||||||
|
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); })
|
||||||
|
.catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)))
|
||||||
|
.finally(() => setCfgSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadVersions = () => {
|
||||||
|
setVersionsLoading(true);
|
||||||
|
brainApi.listSkillVersions(skill.name)
|
||||||
|
.then(setVersions)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setVersionsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRollback = (versionId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Rollback?',
|
||||||
|
`Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Rollback', style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
brainApi.rollbackSkill(skill.name, versionId)
|
||||||
|
.then(r => {
|
||||||
|
Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`);
|
||||||
|
reloadVersions(); onReload();
|
||||||
|
})
|
||||||
|
.catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVersion = (versionId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Version loeschen?',
|
||||||
|
`${versionId} dauerhaft entfernen?`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loeschen', style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
brainApi.deleteSkillVersion(skill.name, versionId)
|
||||||
|
.then(reloadVersions)
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={s.modalTitle} numberOfLines={1}>{skill.name}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||||
|
<Text style={s.label}>Beschreibung</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
|
||||||
|
|
||||||
|
<View style={s.metaBox}>
|
||||||
|
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
|
||||||
|
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
|
||||||
|
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
|
||||||
|
{skill.setup_error ? (
|
||||||
|
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
|
||||||
|
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Args-Inputs */}
|
||||||
|
{args.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
|
||||||
|
{args.map((a: any) => (
|
||||||
|
<View key={a.name} style={{marginBottom: 10}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||||||
|
{a.name}{a.required ? ' *' : ''} {a.description ? `— ${a.description}` : ''}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={argValues[a.name] || ''}
|
||||||
|
onChangeText={(v) => setArg(a.name, v)}
|
||||||
|
placeholder={a.type || 'string'}
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Config-Schema-Form (P3) */}
|
||||||
|
{cfgSchema.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.label, {marginTop: 18}]}>⚙ Konfiguration</Text>
|
||||||
|
{cfgSchema.map((f) => {
|
||||||
|
const isSecret = f.secret || f.type === 'password';
|
||||||
|
const cur = cfgValues[f.name];
|
||||||
|
const isSet = isSecret && cur === '***SET***';
|
||||||
|
const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert'
|
||||||
|
: (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string'));
|
||||||
|
const valStr = cfgDraft[f.name] !== undefined
|
||||||
|
? cfgDraft[f.name]
|
||||||
|
: (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : ''));
|
||||||
|
if (f.type === 'boolean') {
|
||||||
|
const bv = cfgDraft[f.name] !== undefined
|
||||||
|
? (cfgDraft[f.name] === 'true')
|
||||||
|
: (cur === true || cur === 'true');
|
||||||
|
return (
|
||||||
|
<View key={f.name} style={{marginBottom: 10, flexDirection: 'row', alignItems: 'center', gap: 10}}>
|
||||||
|
<Switch value={bv} onValueChange={(v) => setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))}
|
||||||
|
trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" />
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={{color: '#E0E0F0', fontSize: 13}}>{f.label || f.name}</Text>
|
||||||
|
{f.description ? <Text style={{color: '#555570', fontSize: 11}}>{f.description}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View key={f.name} style={{marginBottom: 10}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||||||
|
{f.label || f.name}{isSecret ? ' 🔒' : ''}
|
||||||
|
{f.description ? <Text style={{color: '#555570'}}> — {f.description}</Text> : null}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={valStr}
|
||||||
|
onChangeText={(v) => setCfgDraft(p => ({...p, [f.name]: v}))}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry={isSecret}
|
||||||
|
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#1A1A2E', borderColor: COL_ACTIVE, marginTop: 4}]}
|
||||||
|
onPress={saveConfig}
|
||||||
|
disabled={cfgSaving}
|
||||||
|
>
|
||||||
|
<Text style={{color: COL_ACTIVE, textAlign: 'center', fontWeight: '700'}}>
|
||||||
|
{cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Versionen (P4) */}
|
||||||
|
{versions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.label, {marginTop: 18}]}>📦 Versionen ({versions.length})</Text>
|
||||||
|
{versions.map(v => (
|
||||||
|
<View key={v.version_id} style={[s.metaBox, {marginTop: 6, flexDirection: 'row', alignItems: 'center', gap: 6}]}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#E0E0F0'}]}>{v.version_id}</Text>
|
||||||
|
<Text style={s.meta}>{v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'}</Text>
|
||||||
|
{v.summary ? <Text style={[s.meta, {fontStyle: 'italic'}]} numberOfLines={2}>{v.summary}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => doRollback(v.version_id)}
|
||||||
|
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}>
|
||||||
|
<Text style={{color: COL_ARIA, fontSize: 12}}>↺</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => removeVersion(v.version_id)}
|
||||||
|
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}>
|
||||||
|
<Text style={{color: '#FF6B6B', fontSize: 12}}>🗑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : versionsLoading ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
|
||||||
|
onPress={run}
|
||||||
|
disabled={!skill.active || running}
|
||||||
|
>
|
||||||
|
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
|
||||||
|
{running ? 'läuft...' : '▶ Ausführen'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
|
||||||
|
onPress={loadLogs}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!skill.active ? (
|
||||||
|
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
|
||||||
|
Skill ist deaktiviert — toggle in der Liste zum Aktivieren.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Run-Result */}
|
||||||
|
{runResult ? (
|
||||||
|
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||||
|
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
|
||||||
|
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
|
||||||
|
</Text>
|
||||||
|
{runResult.stdout ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{runResult.stderr ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
{loadingLogs ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||||||
|
) : logs ? (
|
||||||
|
<View style={{marginTop: 14}}>
|
||||||
|
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
|
||||||
|
) : logs.map((log, idx) => (
|
||||||
|
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||||
|
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||||
|
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
|
||||||
|
</Text>
|
||||||
|
{log.stdout ? (
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
|
||||||
|
{String(log.stdout).slice(0, 300)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{height: 30}} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={s.modalFooter}>
|
||||||
|
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
|
||||||
|
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA'}}>Schliessen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Styles ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
toolbar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: '#FFD60A',
|
||||||
|
},
|
||||||
|
iconBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
err: {
|
||||||
|
color: '#FF6B6B',
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
color: '#E0E0F0',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
modalFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#1E1E2E',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#E0E0F0',
|
||||||
|
padding: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
metaBox: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 10,
|
||||||
|
marginTop: 6,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SkillBrowser;
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
/**
|
||||||
|
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
|
||||||
|
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
|
||||||
|
*
|
||||||
|
* Eingesetzt von SettingsScreen → Sektion "Trigger".
|
||||||
|
*
|
||||||
|
* Brain-API ueber brainApi (RVS-Brain-Proxy).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import brainApi, { Trigger } from '../services/brainApi';
|
||||||
|
|
||||||
|
const COL_ACTIVE = '#34C759';
|
||||||
|
const COL_INACTIVE = '#555570';
|
||||||
|
const COL_TIMER = '#0096FF';
|
||||||
|
const COL_WATCHER = '#FFD60A';
|
||||||
|
|
||||||
|
function relTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!t) return '—';
|
||||||
|
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||||||
|
if (diffSec < 60) return `vor ${diffSec}s`;
|
||||||
|
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||||||
|
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||||||
|
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TriggerBrowser: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<Trigger[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
brainApi.listTriggers()
|
||||||
|
.then(t => {
|
||||||
|
// Sortierung: aktive zuerst, dann nach Name
|
||||||
|
t.sort((a, b) => {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
setItems(t);
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const visible = items.filter(t => {
|
||||||
|
if (filter === 'active') return t.active;
|
||||||
|
if (filter === 'inactive') return !t.active;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActive = (t: Trigger) => {
|
||||||
|
brainApi.updateTrigger(t.name, { active: !t.active })
|
||||||
|
.then(() => load())
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTrigger = (t: Trigger) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Trigger löschen?',
|
||||||
|
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Löschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
brainApi.deleteTrigger(t.name)
|
||||||
|
.then(() => { setEditTrigger(null); load(); })
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Trigger }) => {
|
||||||
|
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
|
||||||
|
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
|
||||||
|
<View style={{flex: 1, marginRight: 8}}>
|
||||||
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||||
|
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
|
||||||
|
{item.type === 'watcher' && item.condition ? (
|
||||||
|
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
|
||||||
|
{item.condition}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{item.type === 'timer' && item.fires_at ? (
|
||||||
|
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
|
||||||
|
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||||
|
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={item.active}
|
||||||
|
onValueChange={() => toggleActive(item)}
|
||||||
|
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||||
|
thumbColor="#E0E0F0"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
{/* Filter-Leiste + Reload + Neu */}
|
||||||
|
<View style={s.toolbar}>
|
||||||
|
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[s.chip, filter === f && s.chipActive]}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
>
|
||||||
|
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||||
|
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||||
|
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||||
|
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={visible}
|
||||||
|
keyExtractor={t => t.name}
|
||||||
|
renderItem={renderItem}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||||
|
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{paddingBottom: 20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editTrigger ? (
|
||||||
|
<TriggerEditModal
|
||||||
|
trigger={editTrigger}
|
||||||
|
onClose={() => setEditTrigger(null)}
|
||||||
|
onSaved={() => { setEditTrigger(null); load(); }}
|
||||||
|
onDelete={() => deleteTrigger(editTrigger)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showNew ? (
|
||||||
|
<TriggerNewModal
|
||||||
|
onClose={() => setShowNew(false)}
|
||||||
|
onCreated={() => { setShowNew(false); load(); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Edit-Modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EditProps {
|
||||||
|
trigger: Trigger;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
|
||||||
|
const [message, setMessage] = useState(trigger.message || '');
|
||||||
|
const [condition, setCondition] = useState(trigger.condition || '');
|
||||||
|
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
|
||||||
|
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
|
||||||
|
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setSaving(true);
|
||||||
|
const patch: any = { message };
|
||||||
|
if (trigger.type === 'watcher') {
|
||||||
|
patch.condition = condition;
|
||||||
|
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
|
||||||
|
patch.throttle_sec = parseInt(throttle, 10) || 3600;
|
||||||
|
} else if (trigger.type === 'timer') {
|
||||||
|
patch.fires_at = firesAt;
|
||||||
|
}
|
||||||
|
brainApi.updateTrigger(trigger.name, patch)
|
||||||
|
.then(onSaved)
|
||||||
|
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
|
||||||
|
<View style={s.modalBg}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
|
||||||
|
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{padding: 14}} nestedScrollEnabled>
|
||||||
|
<Text style={s.label}>Nachricht</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
multiline
|
||||||
|
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{trigger.type === 'watcher' ? (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Condition</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={condition}
|
||||||
|
onChangeText={setCondition}
|
||||||
|
placeholder="z.B. near(53.0, 8.5, 300)"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8}}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Check-Intervall (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={checkInterval}
|
||||||
|
onChangeText={setCheckInterval}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Throttle (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={throttle}
|
||||||
|
onChangeText={setThrottle}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={firesAt}
|
||||||
|
onChangeText={setFiresAt}
|
||||||
|
placeholder="2026-05-15T20:00:00+00:00"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={s.metaBox}>
|
||||||
|
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
|
||||||
|
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
|
||||||
|
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
|
||||||
|
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
|
||||||
|
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<View style={s.modalFooter}>
|
||||||
|
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
|
||||||
|
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
|
||||||
|
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Neu-Modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NewProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
|
||||||
|
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [condition, setCondition] = useState('');
|
||||||
|
const [firesAt, setFiresAt] = useState('');
|
||||||
|
const [checkInterval, setCheckInterval] = useState('300');
|
||||||
|
const [throttle, setThrottle] = useState('3600');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (!name.trim() || !message.trim()) {
|
||||||
|
Alert.alert('Name und Nachricht erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const promise = ttype === 'timer'
|
||||||
|
? brainApi.createTimer({
|
||||||
|
name: name.trim(),
|
||||||
|
fires_at: firesAt.trim(),
|
||||||
|
message: message.trim(),
|
||||||
|
})
|
||||||
|
: brainApi.createWatcher({
|
||||||
|
name: name.trim(),
|
||||||
|
condition: condition.trim(),
|
||||||
|
message: message.trim(),
|
||||||
|
check_interval_sec: parseInt(checkInterval, 10) || 300,
|
||||||
|
throttle_sec: parseInt(throttle, 10) || 3600,
|
||||||
|
});
|
||||||
|
promise
|
||||||
|
.then(onCreated)
|
||||||
|
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
|
||||||
|
<View style={s.modalBg}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{padding: 14}} nestedScrollEnabled>
|
||||||
|
<Text style={s.label}>Typ</Text>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
|
||||||
|
{(['watcher', 'timer'] as const).map(t => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t}
|
||||||
|
onPress={() => setTtype(t)}
|
||||||
|
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
|
||||||
|
>
|
||||||
|
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
|
||||||
|
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={s.label}>Name (kebab-case)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="z.B. drk-kreyenbrueck-warnung"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>Nachricht</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
multiline
|
||||||
|
placeholder="Was soll ARIA sagen?"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ttype === 'watcher' ? (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Condition</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={condition}
|
||||||
|
onChangeText={setCondition}
|
||||||
|
placeholder="z.B. entered_near(53.0, 8.5, 300)"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Text style={s.hint}>
|
||||||
|
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
|
||||||
|
</Text>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8}}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Check-Intervall (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={checkInterval}
|
||||||
|
onChangeText={setCheckInterval}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Throttle (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={throttle}
|
||||||
|
onChangeText={setThrottle}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={firesAt}
|
||||||
|
onChangeText={setFiresAt}
|
||||||
|
placeholder="2026-05-15T20:00:00+00:00"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
<View style={s.modalFooter}>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
|
||||||
|
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
toolbar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: '#FFD60A',
|
||||||
|
},
|
||||||
|
iconBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
err: {
|
||||||
|
color: '#FF3B30',
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
modalBg: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderRadius: 12,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 600,
|
||||||
|
maxHeight: '90%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
modalFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#1E1E2E',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#E0E0F0',
|
||||||
|
padding: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: '#555570',
|
||||||
|
fontSize: 11,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginTop: -4,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
metaBox: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 10,
|
||||||
|
marginTop: 10,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TriggerBrowser;
|
||||||
+999
-105
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
PermissionsAndroid,
|
PermissionsAndroid,
|
||||||
|
useWindowDimensions,
|
||||||
|
DeviceEventEmitter,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
@@ -51,9 +53,17 @@ import {
|
|||||||
TTS_SPEED_STORAGE_KEY,
|
TTS_SPEED_STORAGE_KEY,
|
||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import gpsTrackingService from '../services/gpsTracking';
|
import gpsTrackingService, {
|
||||||
|
isBackgroundGpsEnabled,
|
||||||
|
setBackgroundGpsEnabled,
|
||||||
|
ensureBackgroundLocationPermission,
|
||||||
|
} from '../services/gpsTracking';
|
||||||
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||||
import MemoryBrowser from '../components/MemoryBrowser';
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
|
import SkillBrowser from '../components/SkillBrowser';
|
||||||
|
import OAuthBrowser from '../components/OAuthBrowser';
|
||||||
|
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
setWakeReadySoundEnabled,
|
setWakeReadySoundEnabled,
|
||||||
@@ -102,6 +112,9 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||||
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||||
|
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
|
||||||
|
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
|
||||||
|
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
|
||||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -118,6 +131,7 @@ const SOURCE_COLORS: Record<string, string> = {
|
|||||||
// --- Komponente ---
|
// --- Komponente ---
|
||||||
|
|
||||||
const SettingsScreen: React.FC = () => {
|
const SettingsScreen: React.FC = () => {
|
||||||
|
const winDims = useWindowDimensions();
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||||
const [manualToken, setManualToken] = useState('');
|
const [manualToken, setManualToken] = useState('');
|
||||||
const [manualHost, setManualHost] = useState('');
|
const [manualHost, setManualHost] = useState('');
|
||||||
@@ -125,6 +139,9 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [currentMode, setCurrentMode] = useState('normal');
|
const [currentMode, setCurrentMode] = useState('normal');
|
||||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||||
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
||||||
|
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
|
||||||
|
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
|
||||||
|
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
|
||||||
const [scannerVisible, setScannerVisible] = useState(false);
|
const [scannerVisible, setScannerVisible] = useState(false);
|
||||||
const [logTab, setLogTab] = useState<LogTab>('live');
|
const [logTab, setLogTab] = useState<LogTab>('live');
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
@@ -144,6 +161,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||||
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||||
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
|
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
|
||||||
|
const [debugLogsToBridge, setDebugLogsToBridgeState] = useState<boolean>(isDebugLogsToBridge());
|
||||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||||
@@ -192,11 +210,21 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
AsyncStorage.getItem('aria_gps_enabled').then(saved => {
|
||||||
if (saved !== null) setGpsEnabled(saved === 'true');
|
if (saved !== null) setGpsEnabled(saved === 'true');
|
||||||
});
|
});
|
||||||
|
AsyncStorage.getItem('aria_background_mode').then(saved => {
|
||||||
|
// Default ist an — nur explicit 'false' deaktiviert
|
||||||
|
setBackgroundMode(saved !== 'false');
|
||||||
|
});
|
||||||
|
AsyncStorage.getItem('aria_show_hints').then(saved => {
|
||||||
|
// Default ist aus — nur explicit 'true' aktiviert
|
||||||
|
setShowSystemHints(saved === 'true');
|
||||||
|
});
|
||||||
// gpsTrackingService status syncen + auf Aenderungen lauschen
|
// gpsTrackingService status syncen + auf Aenderungen lauschen
|
||||||
setGpsTracking(gpsTrackingService.isActive());
|
setGpsTracking(gpsTrackingService.isActive());
|
||||||
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
||||||
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
|
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
|
||||||
gpsTrackingService.restoreFromStorage().catch(() => {});
|
gpsTrackingService.restoreFromStorage().catch(() => {});
|
||||||
|
// Background-GPS-Toggle initial laden
|
||||||
|
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
|
||||||
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
const n = parseFloat(saved);
|
const n = parseFloat(saved);
|
||||||
@@ -361,6 +389,19 @@ const SettingsScreen: React.FC = () => {
|
|||||||
setConnLog(prev => [...prev.slice(-99), entry]);
|
setConnLog(prev => [...prev.slice(-99), entry]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lokale App-Logs (reportAppDebug/Error) im Live-Logs-Tab anzeigen
|
||||||
|
// — damit Stefan ohne curl direkt in der App sieht was passiert.
|
||||||
|
const localLogSub = DeviceEventEmitter.addListener(APP_LOG_EVENT, (e: any) => {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
id: `applog_${e.ts || Date.now()}_${logIdCounter++}`,
|
||||||
|
timestamp: e.ts || Date.now(),
|
||||||
|
source: e.scope || 'app',
|
||||||
|
message: e.message || '',
|
||||||
|
level: e.level || 'info',
|
||||||
|
};
|
||||||
|
setLogs(prev => [...prev.slice(-200), entry]);
|
||||||
|
});
|
||||||
|
|
||||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
||||||
if (message.type === 'log') {
|
if (message.type === 'log') {
|
||||||
const entry: LogEntry = {
|
const entry: LogEntry = {
|
||||||
@@ -496,6 +537,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
unsubState();
|
unsubState();
|
||||||
unsubMessage();
|
unsubMessage();
|
||||||
unsubLog();
|
unsubLog();
|
||||||
|
localLogSub.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -575,6 +617,44 @@ const SettingsScreen: React.FC = () => {
|
|||||||
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
AsyncStorage.setItem('aria_gps_enabled', String(value)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// --- Hintergrund-Modus Toggle ---
|
||||||
|
|
||||||
|
const handleBackgroundModeToggle = useCallback(async (value: boolean) => {
|
||||||
|
setBackgroundMode(value);
|
||||||
|
AsyncStorage.setItem('aria_background_mode', String(value)).catch(() => {});
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
// Permission fuer Notification (Android 13+) — sonst sieht der User
|
||||||
|
// den Hintergrund-Modus nicht und wundert sich
|
||||||
|
if (Platform.OS === 'android' && Platform.Version >= 33) {
|
||||||
|
await PermissionsAndroid.request(
|
||||||
|
'android.permission.POST_NOTIFICATIONS' as any,
|
||||||
|
{
|
||||||
|
title: 'Hintergrund-Modus',
|
||||||
|
message: 'ARIA zeigt eine Notification damit die App im Hintergrund laufen darf.',
|
||||||
|
buttonPositive: 'Erlauben',
|
||||||
|
buttonNegative: 'Spaeter',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await acquireBackgroundAudio('background');
|
||||||
|
ToastAndroid.show('Hintergrund-Modus aktiv', ToastAndroid.SHORT);
|
||||||
|
} else {
|
||||||
|
await releaseBackgroundAudio('background');
|
||||||
|
ToastAndroid.show('Hintergrund-Modus aus', ToastAndroid.SHORT);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[Settings] Background-Toggle gescheitert:', err?.message || err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- System-Hints Toggle ---
|
||||||
|
|
||||||
|
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
|
||||||
|
setShowSystemHints(value);
|
||||||
|
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- XTTS Voice ---
|
// --- XTTS Voice ---
|
||||||
|
|
||||||
const selectVoice = useCallback((voiceName: string) => {
|
const selectVoice = useCallback((voiceName: string) => {
|
||||||
@@ -868,7 +948,15 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||||
|
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||||
|
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||||
|
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
|
||||||
|
>
|
||||||
|
|
||||||
{currentSection === null && (
|
{currentSection === null && (
|
||||||
<>
|
<>
|
||||||
@@ -1052,6 +1140,101 @@ const SettingsScreen: React.FC = () => {
|
|||||||
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
|
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Background-GPS opt-in — Default AUS. Braucht ACCESS_BACKGROUND_LOCATION
|
||||||
|
(User muss in Android-Settings 'Immer erlauben' aktivieren). */}
|
||||||
|
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>GPS auch im Hintergrund</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Damit ARIA auch unterwegs deine aktuelle Position kennt wenn die
|
||||||
|
App im Hintergrund ist (Auto, Handy-Tasche). Standard: aus.
|
||||||
|
{'\n\n'}
|
||||||
|
Android verlangt fuer Background-GPS, dass du in den
|
||||||
|
System-Einstellungen unter Standort "Immer erlauben" auswaehlst.
|
||||||
|
Beim Aktivieren wird Android-Settings geoeffnet falls noetig.
|
||||||
|
{'\n\n'}
|
||||||
|
Akku-Verbrauch: ~3-5% mehr pro Tag durch dauerhaftes Polling.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={bgGpsEnabled}
|
||||||
|
onValueChange={async (v) => {
|
||||||
|
if (v) {
|
||||||
|
const ok = await ensureBackgroundLocationPermission();
|
||||||
|
if (!ok) {
|
||||||
|
// User muss in Android-Settings auf "Immer erlauben" — Toggle
|
||||||
|
// bleibt aus bis er zurueckkommt und nochmal tippt.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setBackgroundGpsEnabled(true);
|
||||||
|
setBgGpsEnabled(true);
|
||||||
|
// Wenn Tracking bereits laeuft: neu starten damit der
|
||||||
|
// Foreground-Service jetzt mit location-Slot kommt
|
||||||
|
if (gpsTrackingService.isActive()) {
|
||||||
|
gpsTrackingService.stop('bg-toggle');
|
||||||
|
gpsTrackingService.start('bg-aktiviert').catch(() => {});
|
||||||
|
}
|
||||||
|
ToastAndroid.show('Background-GPS aktiviert', ToastAndroid.SHORT);
|
||||||
|
} else {
|
||||||
|
await setBackgroundGpsEnabled(false);
|
||||||
|
setBgGpsEnabled(false);
|
||||||
|
ToastAndroid.show('Background-GPS aus — nur noch Foreground', ToastAndroid.SHORT);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#FF3B30' }}
|
||||||
|
thumbColor={bgGpsEnabled ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === Bubble-Anzeige === */}
|
||||||
|
<Text style={styles.sectionTitle}>Chat-Bubbles</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>System-Hints in Bubbles anzeigen</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Wenn aktiviert: GPS-Position, Barge-In-Hinweise und andere
|
||||||
|
System-Praefixe in eckigen Klammern bleiben in der User-Bubble
|
||||||
|
sichtbar (Debug). Standardmaessig versteckt — Brain bekommt sie
|
||||||
|
trotzdem, sie sind nur fuer dich nicht relevant.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={showSystemHints}
|
||||||
|
onValueChange={handleShowSystemHintsToggle}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||||
|
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === Hintergrund-Modus === */}
|
||||||
|
<Text style={styles.sectionTitle}>Hintergrund-Modus</Text>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.toggleRow}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>App im Hintergrund weiterlaufen</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Haelt die Verbindung zu ARIA auch dann offen wenn die App minimiert
|
||||||
|
ist. Sonst pausiert Android nach ~30s die JS-Engine und Timer-/Watcher-
|
||||||
|
Trigger kommen nicht durch. Notification "ARIA aktiv" bleibt sichtbar
|
||||||
|
waehrend der Modus laeuft (das ist Android-Vorschrift fuer Foreground-
|
||||||
|
Services). Akku-Mehrverbrauch minimal solange ARIA nichts tut.
|
||||||
|
{'\n\n'}
|
||||||
|
Wenn nach Akku-Optimierung Trigger trotzdem nicht durchkommen:
|
||||||
|
Android-Einstellungen → Apps → ARIA Cockpit → Akku → "Uneingeschraenkt"
|
||||||
|
setzen.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={backgroundMode}
|
||||||
|
onValueChange={handleBackgroundModeToggle}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
|
||||||
|
thumbColor={backgroundMode ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
@@ -1682,11 +1865,50 @@ const SettingsScreen: React.FC = () => {
|
|||||||
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
||||||
Tags. Neue Einträge anlegen via "+ Neu".
|
Tags. Neue Einträge anlegen via "+ Neu".
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{height: 600, marginBottom: 8}}>
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
<MemoryBrowser />
|
<MemoryBrowser />
|
||||||
</View>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* === Trigger === */}
|
||||||
|
{currentSection === 'triggers' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Trigger</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||||
|
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
|
||||||
|
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
|
||||||
|
</Text>
|
||||||
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
|
<TriggerBrowser />
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* === Skills === */}
|
||||||
|
{currentSection === 'skills' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Skills</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||||
|
Wiederverwendbare Python-Skills die ARIA selbst gebaut hat oder die Du importiert hast.
|
||||||
|
Toggle aktiv/inaktiv, Tap fuer Details + Run + Logs. Code-Aenderungen macht ARIA via
|
||||||
|
ihr skill_update Brain-Tool — hier nur Manifest-Felder + Run + Cleanup.
|
||||||
|
</Text>
|
||||||
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
|
<SkillBrowser />
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* === OAuth-Apps === */}
|
||||||
|
{currentSection === 'oauth' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>OAuth-Apps</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||||
|
Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...).
|
||||||
|
Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein,
|
||||||
|
dann "Autorisieren ↗" tippen. Custom-Services kannst Du via "+ Custom" anlegen —
|
||||||
|
ARIA kann das auch selbst per Chat ("verbinde mich mit X").
|
||||||
|
</Text>
|
||||||
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
|
<OAuthBrowser />
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
@@ -1710,6 +1932,27 @@ const SettingsScreen: React.FC = () => {
|
|||||||
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
|
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
|
||||||
Debuggen via adb logcat.
|
Debuggen via adb logcat.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* Debug-Logs an Bridge: scharf nur wenn aktiv gebraucht */}
|
||||||
|
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
|
||||||
|
<Text style={styles.toggleLabel}>Debug-Logs an Bridge</Text>
|
||||||
|
<Switch
|
||||||
|
value={debugLogsToBridge}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setDebugLogsToBridge(v);
|
||||||
|
setDebugLogsToBridgeState(v);
|
||||||
|
}}
|
||||||
|
trackColor={{ false: '#3A3A52', true: '#FF9500' }}
|
||||||
|
thumbColor={debugLogsToBridge ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Schickt detaillierte Diagnose-Logs (Wake-Word-Pipeline, Audio-Focus,
|
||||||
|
Background-Service) per RVS an die Bridge — abrufbar via
|
||||||
|
`curl /api/app-log?lines=N` ohne ADB. Default AUS damit kein
|
||||||
|
unnoetiger Traffic + Disk-Schreiben. Crash-Reports (Errors) gehen
|
||||||
|
IMMER, dieser Toggle betrifft nur Info-Logs.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
@@ -1798,7 +2041,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||||
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||||||
<Text style={styles.aboutInfo}>
|
<Text style={styles.aboutInfo}>
|
||||||
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
||||||
Stefans Kommandozentrale.{'\n'}
|
Stefans Kommandozentrale.{'\n'}
|
||||||
Gebaut mit React Native + TypeScript.
|
Gebaut mit React Native + TypeScript.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ function btoaSafe(bin: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
|
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
|
||||||
const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
|
||||||
AudioFocus?: {
|
AudioFocus?: {
|
||||||
requestDuck: () => Promise<boolean>;
|
requestDuck: () => Promise<boolean>;
|
||||||
requestExclusive: () => Promise<boolean>;
|
requestExclusive: () => Promise<boolean>;
|
||||||
|
nudgeMediaResume: () => Promise<boolean>;
|
||||||
release: () => Promise<boolean>;
|
release: () => Promise<boolean>;
|
||||||
kickReleaseMedia: () => Promise<boolean>;
|
kickReleaseMedia: () => Promise<boolean>;
|
||||||
getMode?: () => Promise<number>;
|
getMode?: () => Promise<number>;
|
||||||
@@ -50,8 +51,15 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
|||||||
end: () => Promise<boolean>;
|
end: () => Promise<boolean>;
|
||||||
stop: () => Promise<boolean>;
|
stop: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
PcmStreamRecorder?: {
|
||||||
|
start: () => Promise<boolean>;
|
||||||
|
stop: () => Promise<boolean>;
|
||||||
|
isRecording: () => Promise<boolean>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import rvs from './rvs';
|
||||||
|
|
||||||
// --- Typen ---
|
// --- Typen ---
|
||||||
|
|
||||||
export interface RecordingResult {
|
export interface RecordingResult {
|
||||||
@@ -69,6 +77,19 @@ type RecordingStateCallback = (state: RecordingState) => void;
|
|||||||
type MeterCallback = (db: number) => void;
|
type MeterCallback = (db: number) => void;
|
||||||
type SilenceCallback = () => void;
|
type SilenceCallback = () => void;
|
||||||
|
|
||||||
|
/** Endpoint-Event von der Streaming-Whisper-Bridge — finaler Text +
|
||||||
|
* Echo-Felder. ChatScreen reagiert darauf wie frueher auf
|
||||||
|
* onSilenceDetected, nur dass der Text schon da ist. */
|
||||||
|
export interface SttEndpointEvent {
|
||||||
|
audioRequestId: string;
|
||||||
|
text: string;
|
||||||
|
reason: string; // 'endpoint' | 'stream_end' | 'hardcap'
|
||||||
|
durationS: number;
|
||||||
|
sttMs: number;
|
||||||
|
}
|
||||||
|
type SttEndpointCallback = (e: SttEndpointEvent) => void;
|
||||||
|
type SttPartialCallback = (text: string) => void;
|
||||||
|
|
||||||
// --- Konstanten ---
|
// --- Konstanten ---
|
||||||
|
|
||||||
const AUDIO_SAMPLE_RATE = 16000;
|
const AUDIO_SAMPLE_RATE = 16000;
|
||||||
@@ -285,6 +306,30 @@ class AudioService {
|
|||||||
// Position-Berechnen vom playbackStarted abziehen
|
// Position-Berechnen vom playbackStarted abziehen
|
||||||
private readonly LEADING_SILENCE_SEC = 0.3;
|
private readonly LEADING_SILENCE_SEC = 0.3;
|
||||||
|
|
||||||
|
// ── Streaming-STT-Session-State ──
|
||||||
|
// Aktuelle Session-ID (requestId der whisper-bridge). Leer wenn kein Stream
|
||||||
|
// aktiv. Wird beim Eintreffen von Chunks geprueft damit wir nicht versehent-
|
||||||
|
// lich Chunks einer alten Session in eine neue mischen.
|
||||||
|
private streamRequestId: string = '';
|
||||||
|
private streamAudioRequestId: string = '';
|
||||||
|
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
|
||||||
|
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
|
||||||
|
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
|
||||||
|
private streamEndpointFired: boolean = false;
|
||||||
|
// Subscriber-Handles fuer Native-Events + RVS-Listener (cleanup beim stop)
|
||||||
|
private streamPcmChunkSub: { remove: () => void } | null = null;
|
||||||
|
private streamPcmErrorSub: { remove: () => void } | null = null;
|
||||||
|
private streamRvsUnsub: (() => void) | null = null;
|
||||||
|
// No-speech-Watchdog: wenn nach N ms noch kein einziger stt_partial kam,
|
||||||
|
// brechen wir die Session ab (Stille → User hat nix gesagt → Konversation
|
||||||
|
// beenden). Ersetzt den alten vad noSpeechTimer.
|
||||||
|
private streamNoSpeechTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private streamGotPartial: boolean = false;
|
||||||
|
private streamHardCapTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Endpoint/Partial-Callbacks fuer ChatScreen
|
||||||
|
private endpointListeners: SttEndpointCallback[] = [];
|
||||||
|
private partialListeners: SttPartialCallback[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.recorder = new AudioRecorderPlayer();
|
this.recorder = new AudioRecorderPlayer();
|
||||||
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
|
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
|
||||||
@@ -309,6 +354,58 @@ class AudioService {
|
|||||||
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
|
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
|
||||||
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
|
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
|
||||||
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
|
this._cleanupStaleCacheFiles(5 * 60 * 1000).catch(() => {});
|
||||||
|
|
||||||
|
// RVS-Listener fuer Streaming-STT-Antworten der Whisper-Bridge.
|
||||||
|
// Wir subscribed permanent — gefiltert wird ueber streamRequestId-Match.
|
||||||
|
// Das macht startStreamingRecording einfacher (kein subscribe/unsubscribe
|
||||||
|
// pro Session noetig).
|
||||||
|
try {
|
||||||
|
this.streamRvsUnsub = rvs.onMessage((msg) => {
|
||||||
|
const t = msg?.type;
|
||||||
|
if (t !== 'stt_partial' && t !== 'stt_endpoint' && t !== 'stt_stream_done') return;
|
||||||
|
const p = (msg as any).payload || {};
|
||||||
|
const reqId = String(p.requestId || '');
|
||||||
|
if (!reqId || reqId !== this.streamRequestId) return;
|
||||||
|
if (t === 'stt_partial') {
|
||||||
|
const text = String(p.text || '');
|
||||||
|
this.streamGotPartial = true;
|
||||||
|
// Sobald wir ueberhaupt mal Text gekriegt haben, ist der no-speech
|
||||||
|
// Watchdog erledigt.
|
||||||
|
if (this.streamNoSpeechTimer) {
|
||||||
|
clearTimeout(this.streamNoSpeechTimer);
|
||||||
|
this.streamNoSpeechTimer = null;
|
||||||
|
}
|
||||||
|
this.partialListeners.forEach(cb => {
|
||||||
|
try { cb(text); } catch (e) { console.warn('[Audio] partial listener err:', e); }
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t === 'stt_endpoint') {
|
||||||
|
const ev: SttEndpointEvent = {
|
||||||
|
audioRequestId: String(p.audioRequestId || ''),
|
||||||
|
text: String(p.text || ''),
|
||||||
|
reason: String(p.reason || ''),
|
||||||
|
durationS: Number(p.durationS || 0),
|
||||||
|
sttMs: Number(p.sttMs || 0),
|
||||||
|
};
|
||||||
|
console.log('[Audio] stt_endpoint: %dms, %.1fs Audio, text=%r',
|
||||||
|
ev.sttMs, ev.durationS, ev.text.slice(0, 80));
|
||||||
|
// Wir stoppen die Aufnahme — whisper hat alles was es braucht.
|
||||||
|
// Kein stt_stream_end senden: das Endpoint kam von der Bridge,
|
||||||
|
// sie hat schon finalisiert.
|
||||||
|
this._fireEndpoint(ev);
|
||||||
|
this._cleanupStreamLocal('endpoint');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t === 'stt_stream_done') {
|
||||||
|
// Idempotent — falls cleanup nach endpoint schon lief, harmlos.
|
||||||
|
this._cleanupStreamLocal('stream_done');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Audio] RVS-Listener-Subscribe fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
|
||||||
@@ -332,6 +429,13 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
console.log('[Audio] AudioFocus jetzt released');
|
console.log('[Audio] AudioFocus jetzt released');
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
|
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
|
||||||
|
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
|
||||||
|
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
|
||||||
|
// 50ms Delay damit das Abandon erst durch ist.
|
||||||
|
setTimeout(() => {
|
||||||
|
AudioFocus?.nudgeMediaResume().catch(() => {});
|
||||||
|
}, 50);
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,6 +831,31 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aufnahme abbrechen ohne RecordingResult zu emittieren — z.B. bei
|
||||||
|
* Wake-Word-False-Positive beim App-Resume aus laengerem Hintergrund.
|
||||||
|
* Aufgenommene Datei wird sofort verworfen. */
|
||||||
|
async cancelRecording(): Promise<void> {
|
||||||
|
if (this.recordingState !== 'recording') return;
|
||||||
|
console.log('[Audio] Aufnahme abgebrochen (cancel)');
|
||||||
|
this.vadEnabled = false;
|
||||||
|
if (this.vadTimer) { clearInterval(this.vadTimer); this.vadTimer = null; }
|
||||||
|
if (this.maxDurationTimer) { clearTimeout(this.maxDurationTimer); this.maxDurationTimer = null; }
|
||||||
|
if (this.noSpeechTimer) { clearTimeout(this.noSpeechTimer); this.noSpeechTimer = null; }
|
||||||
|
try {
|
||||||
|
const path = await this.recorder.stopRecorder();
|
||||||
|
this.recorder.removeRecordBackListener();
|
||||||
|
// Datei loeschen wenn da
|
||||||
|
if (path && path !== 'Already stopped') {
|
||||||
|
const local = path.replace(/^file:\/\//, '');
|
||||||
|
try { await RNFS.unlink(local); } catch {}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Audio] cancelRecording stop fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
this._releaseFocusDeferred();
|
||||||
|
this.setState('idle');
|
||||||
|
}
|
||||||
|
|
||||||
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
/** Aufnahme stoppen und Ergebnis zurueckgeben */
|
||||||
async stopRecording(): Promise<RecordingResult | null> {
|
async stopRecording(): Promise<RecordingResult | null> {
|
||||||
if (this.recordingState !== 'recording') {
|
if (this.recordingState !== 'recording') {
|
||||||
@@ -789,6 +918,282 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// STREAMING-AUFNAHME (Phase 1+2 — PCM live an Whisper-Bridge)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Startet eine Streaming-STT-Session.
|
||||||
|
*
|
||||||
|
* Statt eine MP4-Datei aufzunehmen und am Ende hochzuladen, oeffnet der
|
||||||
|
* PcmStreamRecorder (16 kHz mono s16le) ein AudioRecord und schickt
|
||||||
|
* alle 200ms einen PCM-Chunk via rvs.send('stt_audio_chunk') an die
|
||||||
|
* whisper-bridge. Diese transkribiert live und feuert stt_endpoint
|
||||||
|
* sobald der erkannte Text fuer endpointMs nicht mehr waechst.
|
||||||
|
*
|
||||||
|
* Auf stt_endpoint reagiert audio.ts indem es PcmStreamRecorder stoppt
|
||||||
|
* und endpointListeners feuert — ChatScreen baut dann die Chat-Bubble.
|
||||||
|
* Den eigentlichen Brain-Call macht aria-bridge direkt nach stt_endpoint,
|
||||||
|
* KEIN Audio-Roundtrip ueber die App noetig.
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* audioRequestId — eindeutige Korrelations-ID fuer die "wird
|
||||||
|
* verarbeitet"-Bubble (gleiche Semantik wie beim
|
||||||
|
* Legacy-Pfad mit rvs.send('audio')).
|
||||||
|
* voice/speed — TTS-Echo-Felder, werden an Brain weitergegeben.
|
||||||
|
* interrupted — true bei Barge-In waehrend ARIA noch sprach.
|
||||||
|
* location — GPS, falls vorhanden.
|
||||||
|
* noSpeechTimeoutMs — wenn nach so vielen ms KEIN stt_partial kam
|
||||||
|
* (= Whisper hat nix erkannt), brechen wir die
|
||||||
|
* Session ab. 0 = kein Watchdog.
|
||||||
|
* endpointMs — Schwellwert fuer Endpoint (Stille = kein neuer
|
||||||
|
* Text). Default 1500ms — Whisper-Bridge nutzt
|
||||||
|
* den Wert wenn mitgesendet.
|
||||||
|
* hardCapMs — Schmerzgrenze. Default 60s.
|
||||||
|
*/
|
||||||
|
async startStreamingRecording(opts: {
|
||||||
|
audioRequestId: string;
|
||||||
|
voice?: string;
|
||||||
|
speed?: number;
|
||||||
|
interrupted?: boolean;
|
||||||
|
location?: any;
|
||||||
|
noSpeechTimeoutMs?: number;
|
||||||
|
endpointMs?: number;
|
||||||
|
hardCapMs?: number;
|
||||||
|
}): Promise<{ requestId: string; ok: boolean }> {
|
||||||
|
if (this.recordingState !== 'idle') {
|
||||||
|
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||||
|
return { requestId: '', ok: false };
|
||||||
|
}
|
||||||
|
if (!PcmStreamRecorder) {
|
||||||
|
console.warn('[Audio] PcmStreamRecorder Native-Modul nicht verfuegbar');
|
||||||
|
return { requestId: '', ok: false };
|
||||||
|
}
|
||||||
|
const hasPermission = await this.requestMicrophonePermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||||
|
return { requestId: '', ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||||
|
this.stopPlayback();
|
||||||
|
|
||||||
|
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
|
this.streamRequestId = requestId;
|
||||||
|
this.streamAudioRequestId = opts.audioRequestId || '';
|
||||||
|
this.streamGotPartial = false;
|
||||||
|
this.streamEndpointFired = false;
|
||||||
|
this.recordingStartTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await acquireBackgroundAudio('rec');
|
||||||
|
|
||||||
|
// PcmStreamChunk-Subscriber AUFSETZEN BEVOR der Recorder startet —
|
||||||
|
// sonst koennten die ersten 1-2 Chunks ins Leere gehen.
|
||||||
|
try {
|
||||||
|
const emitter = new NativeEventEmitter(NativeModules.PcmStreamRecorder as any);
|
||||||
|
this.streamPcmChunkSub = emitter.addListener('PcmStreamChunk', (e: any) => {
|
||||||
|
// Nur Chunks der aktuellen Session weiterleiten — verhindert dass
|
||||||
|
// ein verspaeteter Chunk in einer neuen Session landet.
|
||||||
|
if (!this.streamRequestId) return;
|
||||||
|
const sessionId = this.streamRequestId;
|
||||||
|
rvs.send('stt_audio_chunk' as any, {
|
||||||
|
requestId: sessionId,
|
||||||
|
pcm: String(e?.pcm || ''),
|
||||||
|
seq: Number(e?.seq || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
|
||||||
|
console.warn('[Audio] PcmStreamRecorder-Fehler:', e?.error);
|
||||||
|
this._cleanupStreamLocal('pcm-error');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = await PcmStreamRecorder.start();
|
||||||
|
if (!started) {
|
||||||
|
throw new Error('PcmStreamRecorder.start returned false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
|
||||||
|
this._cancelDeferredFocusRelease();
|
||||||
|
AudioFocus?.requestExclusive().catch(() => {});
|
||||||
|
|
||||||
|
this.setState('recording');
|
||||||
|
|
||||||
|
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
|
||||||
|
rvs.send('stt_stream_start' as any, {
|
||||||
|
requestId,
|
||||||
|
audioRequestId: opts.audioRequestId || '',
|
||||||
|
voice: opts.voice || '',
|
||||||
|
speed: typeof opts.speed === 'number' ? opts.speed : 1.0,
|
||||||
|
interrupted: !!opts.interrupted,
|
||||||
|
location: opts.location || null,
|
||||||
|
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
|
||||||
|
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
|
||||||
|
sampleRate: 16000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||||
|
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
||||||
|
// hat der User vermutlich nix gesagt → Session beenden.
|
||||||
|
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
|
||||||
|
if (noSpeechMs > 0) {
|
||||||
|
this.streamNoSpeechTimer = setTimeout(() => {
|
||||||
|
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
||||||
|
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
|
||||||
|
requestId.slice(0, 12), noSpeechMs);
|
||||||
|
this.cancelStreamingRecording('no-speech').catch(() => {});
|
||||||
|
}
|
||||||
|
}, noSpeechMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
||||||
|
const hardCapMs = Number(opts.hardCapMs || 60000);
|
||||||
|
this.streamHardCapTimer = setTimeout(() => {
|
||||||
|
if (this.streamRequestId === requestId) {
|
||||||
|
console.log('[Audio] Stream %s: app-side hardcap %dms erreicht → end',
|
||||||
|
requestId.slice(0, 12), hardCapMs);
|
||||||
|
this.stopStreamingRecording('hardcap').catch(() => {});
|
||||||
|
}
|
||||||
|
}, hardCapMs + 2000); // +2s damit Bridge zuerst feuert wenn moeglich
|
||||||
|
|
||||||
|
console.log('[Audio] Streaming-Aufnahme gestartet (requestId=%s, audioRequestId=%s)',
|
||||||
|
requestId.slice(0, 12), (opts.audioRequestId || '').slice(0, 16));
|
||||||
|
return { requestId, ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Audio] startStreamingRecording fehlgeschlagen:', err);
|
||||||
|
this._cleanupStreamLocal('start-failed');
|
||||||
|
return { requestId: '', ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sauberer User-initiated Stop. Sendet stt_stream_end an die Bridge,
|
||||||
|
* die noch ihren Final-Transcribe macht.
|
||||||
|
*
|
||||||
|
* Plus: Fallback-Timer (3s). Wenn die Bridge nicht antwortet (z.B. weil
|
||||||
|
* veraltete Version ohne Streaming-Handler laeuft), feuern wir den
|
||||||
|
* Endpoint-Listener trotzdem mit text='' damit die App-UI nicht in
|
||||||
|
* "wird verarbeitet..." haengt. ChatScreen behandelt das wie den
|
||||||
|
* No-Speech-Fall (Bubble weg + endConversation). */
|
||||||
|
async stopStreamingRecording(reason: string = 'user'): Promise<void> {
|
||||||
|
const reqId = this.streamRequestId;
|
||||||
|
if (!reqId) return;
|
||||||
|
const audioReqId = this.streamAudioRequestId;
|
||||||
|
try {
|
||||||
|
rvs.send('stt_stream_end' as any, { requestId: reqId, reason });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
// Recorder lokal abschalten — Bridge feuert dann ihrerseits noch
|
||||||
|
// stt_endpoint + stt_stream_done.
|
||||||
|
this._cleanupStreamLocal(`stop:${reason}`);
|
||||||
|
// Fallback-Watchdog: nach 3s noch immer kein Endpoint via RVS angekommen
|
||||||
|
// → _fireEndpoint mit text='' (idempotent via streamEndpointFired-Latch,
|
||||||
|
// d.h. wenn echtes stt_endpoint zwischen jetzt und +3s ankommt feuert
|
||||||
|
// dieser Fallback NICHT).
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.streamEndpointFired) return;
|
||||||
|
console.log('[Audio] stopStreamingRecording: 3s ohne Bridge-Antwort — fallback fire');
|
||||||
|
this._fireEndpoint({
|
||||||
|
audioRequestId: audioReqId,
|
||||||
|
text: '',
|
||||||
|
reason: `stop:${reason}:no-response`,
|
||||||
|
durationS: 0,
|
||||||
|
sttMs: 0,
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abbruch ohne dass Brain den Text verarbeitet — z.B. wenn der User
|
||||||
|
* im Conversation-Window nichts sagt oder cancel drueckt.
|
||||||
|
*
|
||||||
|
* Feuert endpointListeners mit text='' damit ChatScreen den Fall genauso
|
||||||
|
* behandeln kann wie frueher onSilenceDetected→stopRecording()→null:
|
||||||
|
* Konversation beenden, Ohr zurueck auf armed. */
|
||||||
|
async cancelStreamingRecording(reason: string = 'cancel'): Promise<void> {
|
||||||
|
const reqId = this.streamRequestId;
|
||||||
|
if (!reqId) return;
|
||||||
|
const audioReqId = this.streamAudioRequestId;
|
||||||
|
try {
|
||||||
|
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` });
|
||||||
|
} catch {}
|
||||||
|
this._cleanupStreamLocal(`cancel:${reason}`);
|
||||||
|
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
|
||||||
|
this._fireEndpoint({
|
||||||
|
audioRequestId: audioReqId,
|
||||||
|
text: '',
|
||||||
|
reason: `cancel:${reason}`,
|
||||||
|
durationS: 0,
|
||||||
|
sttMs: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feuert den Endpoint-Listener — aber nur einmal pro Session-Cycle.
|
||||||
|
* Wird sowohl vom RVS-stt_endpoint-Pfad als auch vom Fallback-Watchdog
|
||||||
|
* und cancelStreamingRecording aufgerufen. */
|
||||||
|
private _fireEndpoint(ev: SttEndpointEvent): void {
|
||||||
|
if (this.streamEndpointFired) return;
|
||||||
|
this.streamEndpointFired = true;
|
||||||
|
this.endpointListeners.forEach(cb => {
|
||||||
|
try { cb(ev); } catch (e) { console.warn('[Audio] endpoint listener err:', e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur-lokale Cleanup: PcmStreamRecorder stoppen, Listener entfernen,
|
||||||
|
* AudioFocus freigeben, State zurueck auf idle. Nicht ueber RVS
|
||||||
|
* kommunizieren — Caller hat das schon erledigt (oder eben nicht
|
||||||
|
* noetig wenn Bridge das Endpoint gefeuert hat). */
|
||||||
|
private _cleanupStreamLocal(reason: string): void {
|
||||||
|
if (!this.streamRequestId) return;
|
||||||
|
console.log('[Audio] Stream cleanup (%s)', reason);
|
||||||
|
this.streamRequestId = '';
|
||||||
|
this.streamAudioRequestId = '';
|
||||||
|
this.streamGotPartial = false;
|
||||||
|
if (this.streamNoSpeechTimer) {
|
||||||
|
clearTimeout(this.streamNoSpeechTimer);
|
||||||
|
this.streamNoSpeechTimer = null;
|
||||||
|
}
|
||||||
|
if (this.streamHardCapTimer) {
|
||||||
|
clearTimeout(this.streamHardCapTimer);
|
||||||
|
this.streamHardCapTimer = null;
|
||||||
|
}
|
||||||
|
if (this.streamPcmChunkSub) {
|
||||||
|
try { this.streamPcmChunkSub.remove(); } catch {}
|
||||||
|
this.streamPcmChunkSub = null;
|
||||||
|
}
|
||||||
|
if (this.streamPcmErrorSub) {
|
||||||
|
try { this.streamPcmErrorSub.remove(); } catch {}
|
||||||
|
this.streamPcmErrorSub = null;
|
||||||
|
}
|
||||||
|
PcmStreamRecorder?.stop().catch(() => {});
|
||||||
|
this._releaseFocusDeferred();
|
||||||
|
this.setState('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True wenn aktuell eine Streaming-Session laeuft. */
|
||||||
|
isStreamingRecording(): boolean {
|
||||||
|
return !!this.streamRequestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe auf stt_endpoint — feuert wenn die Whisper-Bridge erkannt
|
||||||
|
* hat, dass der User fertig gesprochen hat (ML-Endpointer). */
|
||||||
|
onSttEndpoint(callback: SttEndpointCallback): () => void {
|
||||||
|
this.endpointListeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
this.endpointListeners = this.endpointListeners.filter(cb => cb !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe auf stt_partial — Live-Transkript-Updates (optional fuer
|
||||||
|
* UI-Feedback in der Voice-Bubble). */
|
||||||
|
onSttPartial(callback: SttPartialCallback): () => void {
|
||||||
|
this.partialListeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
this.partialListeners = this.partialListeners.filter(cb => cb !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- Wiedergabe ---
|
// --- Wiedergabe ---
|
||||||
|
|
||||||
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
|
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Background-Audio: ARIAs TTS, Mic-Aufnahme und Wake-Word-Lauschen sollen
|
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
|
||||||
* auch bei minimierter App weiterlaufen. Wir starten dafuer einen Foreground-
|
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
|
||||||
|
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
|
||||||
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
|
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
|
||||||
* persistente Notification zeigt waehrend irgendein Audio-Slot aktiv ist.
|
* persistente Notification zeigt solange irgendein Slot aktiv ist.
|
||||||
*
|
*
|
||||||
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
||||||
* - 'tts' : ARIA spricht
|
* - 'tts' : ARIA spricht
|
||||||
* - 'rec' : Aufnahme laeuft
|
* - 'rec' : Aufnahme laeuft
|
||||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
||||||
|
* - 'location' : Background-GPS-Tracking (opt-in in Settings)
|
||||||
|
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
|
||||||
|
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
|
||||||
|
* → Trigger-Replies, Reconnects, Push-Reaktionen.
|
||||||
*
|
*
|
||||||
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
||||||
* den hoechstprioren Slot an (tts > rec > wake).
|
* den hoechstprioren Slot an (tts > rec > wake > location > background).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NativeModules } from 'react-native';
|
import { NativeModules } from 'react-native';
|
||||||
@@ -23,12 +28,13 @@ interface BackgroundAudioNative {
|
|||||||
|
|
||||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||||
|
|
||||||
type Slot = 'tts' | 'rec' | 'wake';
|
type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
|
||||||
|
|
||||||
const slots = new Set<Slot>();
|
const slots = new Set<Slot>();
|
||||||
|
|
||||||
// Prioritaet fuer den Notification-Text — hoechste zuerst.
|
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake'];
|
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||||
|
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'location', 'background'];
|
||||||
|
|
||||||
function topReason(): string {
|
function topReason(): string {
|
||||||
for (const s of PRIORITY) {
|
for (const s of PRIORITY) {
|
||||||
@@ -42,6 +48,7 @@ async function applyState(): Promise<void> {
|
|||||||
if (slots.size === 0) {
|
if (slots.size === 0) {
|
||||||
try { await BackgroundAudio.stop(); } catch {}
|
try { await BackgroundAudio.stop(); } catch {}
|
||||||
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
|
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
|
||||||
|
import('./logger').then(m => m.reportAppDebug('bg.stop', 'service stopped')).catch(()=>{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reason = topReason();
|
const reason = topReason();
|
||||||
@@ -49,8 +56,10 @@ async function applyState(): Promise<void> {
|
|||||||
await BackgroundAudio.start(reason);
|
await BackgroundAudio.start(reason);
|
||||||
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
|
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
|
||||||
reason, [...slots].join('+'));
|
reason, [...slots].join('+'));
|
||||||
|
import('./logger').then(m => m.reportAppDebug('bg.start', `slot=${reason} all=[${[...slots].join(',')}]`)).catch(()=>{});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
|
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('bg.start.fail', err?.message || String(err))).catch(()=>{});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,83 @@ export interface Memory {
|
|||||||
attachments?: MemoryAttachment[];
|
attachments?: MemoryAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** OAuth-Service-Status wie aus Brain `/oauth/services` zurueckkommt. */
|
||||||
|
export interface OAuthServiceStatus {
|
||||||
|
service: string;
|
||||||
|
configured: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
expiresAt?: number | null;
|
||||||
|
expiresInSec?: number | null;
|
||||||
|
hasRefresh: boolean;
|
||||||
|
scope?: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth-App-Config (client_id/scopes/URLs) — client_secret kommt NIE rausgegeben. */
|
||||||
|
export interface OAuthAppConfig {
|
||||||
|
client_id: string;
|
||||||
|
has_client_secret: boolean;
|
||||||
|
scopes?: string[] | null;
|
||||||
|
auth_url?: string | null;
|
||||||
|
token_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||||
|
export interface Skill {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
execution: string; // local-venv | local-bin | bash
|
||||||
|
entry: string; // run.py | run.sh
|
||||||
|
args?: any[]; // [{name, type, required, description}]
|
||||||
|
requires?: { pip?: string[]; binaries?: string[] };
|
||||||
|
active: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
last_used?: string | null;
|
||||||
|
use_count?: number;
|
||||||
|
version?: string;
|
||||||
|
author?: string; // "aria" | "stefan"
|
||||||
|
setup_error?: string;
|
||||||
|
// P3: konfigurierbare Werte (API-Keys, IDs etc.) — Stefan setzt sie hier,
|
||||||
|
// Skill bekommt sie als CFG_<NAME> ENV. Werte selbst kommen via /config.
|
||||||
|
config_schema?: SkillConfigField[];
|
||||||
|
// P4: Versions-Historie. Detail-Liste kommt via /versions.
|
||||||
|
version_history?: { version_id: string; archived_at?: string; summary?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillConfigField {
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'password';
|
||||||
|
label?: string;
|
||||||
|
secret?: boolean;
|
||||||
|
description?: string;
|
||||||
|
default?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillVersion {
|
||||||
|
version_id: string;
|
||||||
|
archived_at?: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||||
|
export interface Trigger {
|
||||||
|
name: string;
|
||||||
|
type: 'timer' | 'watcher' | string;
|
||||||
|
active: boolean;
|
||||||
|
author?: string;
|
||||||
|
message: string;
|
||||||
|
fires_at?: string; // ISO, nur timer
|
||||||
|
condition?: string; // nur watcher
|
||||||
|
check_interval_sec?: number; // nur watcher
|
||||||
|
throttle_sec?: number; // nur watcher
|
||||||
|
fire_count?: number;
|
||||||
|
last_fired_at?: string | null;
|
||||||
|
last_checked_at?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const brainApi = {
|
export const brainApi = {
|
||||||
@@ -215,6 +292,226 @@ export const brainApi = {
|
|||||||
{ expectBinary: true, timeoutMs: 60000 },
|
{ expectBinary: true, timeoutMs: 60000 },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Triggers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Liste aller Trigger (aktive + inaktive).
|
||||||
|
* Brain returnt {triggers: [...]} — wir unwrappen damit der Caller einfach
|
||||||
|
* t.sort/filter/map nutzen kann. Ohne das Unwrap warf t.sort() eine
|
||||||
|
* TypeError-Exception und der TriggerBrowser blieb leer. */
|
||||||
|
listTriggers(): Promise<Trigger[]> {
|
||||||
|
return _send('/triggers/list').then((r: any) => Array.isArray(r) ? r : (r?.triggers || []));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||||
|
getTrigger(name: string): Promise<Trigger> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
|
||||||
|
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
|
||||||
|
return _send('/triggers/conditions');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trigger-Logs (last N Feuerungen). */
|
||||||
|
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
|
||||||
|
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
|
||||||
|
return _send('/triggers/timer', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { author: 'app', ...body },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Watcher anlegen. */
|
||||||
|
createWatcher(body: {
|
||||||
|
name: string;
|
||||||
|
condition: string;
|
||||||
|
message: string;
|
||||||
|
check_interval_sec?: number;
|
||||||
|
throttle_sec?: number;
|
||||||
|
author?: string;
|
||||||
|
}): Promise<Trigger> {
|
||||||
|
return _send('/triggers/watcher', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { author: 'app', ...body },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
|
||||||
|
updateTrigger(name: string, body: Partial<{
|
||||||
|
active: boolean;
|
||||||
|
message: string;
|
||||||
|
condition: string;
|
||||||
|
throttle_sec: number;
|
||||||
|
check_interval_sec: number;
|
||||||
|
fires_at: string;
|
||||||
|
}>): Promise<Trigger> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trigger loeschen. */
|
||||||
|
deleteTrigger(name: string): Promise<{ deleted: string }> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Skills ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Liste aller Skills (aktive + inaktive). Brain returnt {skills: [...]}. */
|
||||||
|
listSkills(): Promise<Skill[]> {
|
||||||
|
return _send('/skills/list').then((r: any) => Array.isArray(r) ? r : (r?.skills || []));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Einzelnen Skill holen (inkl. setup_error, last_used, use_count). */
|
||||||
|
getSkill(name: string): Promise<Skill> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Skill ausfuehren (mit args als ENV ARG_XXX). Skill-Run kann lange dauern,
|
||||||
|
* 5 min Default-Timeout. */
|
||||||
|
runSkill(name: string, args: Record<string, any> = {}): Promise<{
|
||||||
|
ok: boolean; exit_code: number; stdout: string; stderr: string;
|
||||||
|
duration_sec: number; log_path?: string;
|
||||||
|
}> {
|
||||||
|
return _send('/skills/run', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, args, timeout_sec: 300 },
|
||||||
|
timeoutMs: 320000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Skill-Manifest aendern (description, active, args...). Code-Aenderungen
|
||||||
|
* gehen ueber ARIAs eigene skill_update-Tool — die App-UI sollte sie
|
||||||
|
* NICHT direkt anbieten (zu fehleranfaellig). */
|
||||||
|
updateSkill(name: string, body: Partial<{
|
||||||
|
description: string;
|
||||||
|
active: boolean;
|
||||||
|
args: any[];
|
||||||
|
version: string;
|
||||||
|
}>): Promise<Skill> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Skill loeschen (samt venv + logs). */
|
||||||
|
deleteSkill(name: string): Promise<{ deleted: string }> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Letzte Run-Logs eines Skills. */
|
||||||
|
getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`)
|
||||||
|
.then((r: any) => Array.isArray(r) ? r : (r?.logs || []));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** P3: Config-Schema + aktuelle Werte (secret-Felder gemaskt mit '***SET***'). */
|
||||||
|
getSkillConfig(name: string): Promise<{ schema: SkillConfigField[]; values: Record<string, any> }> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/config`)
|
||||||
|
.then((r: any) => ({ schema: r?.schema || [], values: r?.values || {} }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** P3: Config-Werte komplett ueberschreiben. Werte greifen ab dem naechsten Run. */
|
||||||
|
setSkillConfig(name: string, values: Record<string, any>): Promise<{ ok: boolean; values: Record<string, any> }> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { values },
|
||||||
|
timeoutMs: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** P4: Liste archivierter Versionen, neueste zuerst. */
|
||||||
|
listSkillVersions(name: string): Promise<SkillVersion[]> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/versions`)
|
||||||
|
.then((r: any) => r?.versions || []);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** P4: Rollback auf eine fruehere Version. Aktueller Stand wird automatisch gesichert. */
|
||||||
|
rollbackSkill(name: string, versionId: string): Promise<{ ok: boolean; rolled_back_to: string; safety_snapshot: string }> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/rollback`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { version_id: versionId },
|
||||||
|
timeoutMs: 60000, // venv-Rebuild kann dauern
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** P4: Einzelne Version dauerhaft loeschen. */
|
||||||
|
deleteSkillVersion(name: string, versionId: string): Promise<{ ok: boolean; deleted: string }> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
timeoutMs: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── OAuth ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Liste aller Services mit Auth-Status (configured/authenticated/expires). */
|
||||||
|
listOAuthServices(): Promise<{ services: OAuthServiceStatus[] }> {
|
||||||
|
return _send('/oauth/services');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Persistierte Provider-Configs (URLs/scopes/client_id, KEIN client_secret). */
|
||||||
|
getOAuthApps(): Promise<{ apps: Record<string, OAuthAppConfig>; defaults: string[] }> {
|
||||||
|
return _send('/oauth/apps');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Provider-Config setzen/aktualisieren. Leerer client_secret laesst
|
||||||
|
* den bestehenden Wert stehen. */
|
||||||
|
saveOAuthApp(body: {
|
||||||
|
service: string;
|
||||||
|
client_id?: string;
|
||||||
|
client_secret?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
auth_url?: string;
|
||||||
|
token_url?: string;
|
||||||
|
}): Promise<{ ok: boolean; service: string }> {
|
||||||
|
return _send('/oauth/apps', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Service-Eintrag komplett entfernen (incl. Token). */
|
||||||
|
deleteOAuthApp(service: string): Promise<{ ok: boolean }> {
|
||||||
|
return _send(`/oauth/apps/${encodeURIComponent(service)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Authorize-URL bauen (Brain speichert state, gibt url + redirect_uri zurueck). */
|
||||||
|
authorizeOAuth(service: string, scopes?: string[]): Promise<{
|
||||||
|
url: string; state: string; redirect_uri: string; service: string;
|
||||||
|
}> {
|
||||||
|
return _send('/oauth/authorize', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { service, scopes },
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Token loeschen (lokal — kein Provider-Revoke). */
|
||||||
|
revokeOAuth(service: string): Promise<{ ok: boolean }> {
|
||||||
|
return _send(`/oauth/${encodeURIComponent(service)}/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default brainApi;
|
export default brainApi;
|
||||||
|
|||||||
@@ -14,9 +14,62 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
|
import { Linking, PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
import rvs from './rvs';
|
import rvs from './rvs';
|
||||||
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from './backgroundAudio';
|
||||||
|
|
||||||
|
// Opt-in Background-GPS — Settings-Toggle "GPS auch im Hintergrund".
|
||||||
|
// Default AUS. Wenn AN: ACCESS_BACKGROUND_LOCATION-Permission noetig
|
||||||
|
// (kann nicht ueber Standard-Dialog angefordert werden, User muss in
|
||||||
|
// Android-Settings auf "Immer erlauben" gehen) + ForegroundService mit
|
||||||
|
// foregroundServiceType=location wird hochgezogen.
|
||||||
|
export const BG_GPS_STORAGE_KEY = 'aria_gps_background_enabled';
|
||||||
|
|
||||||
|
export async function isBackgroundGpsEnabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const v = await AsyncStorage.getItem(BG_GPS_STORAGE_KEY);
|
||||||
|
return v === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setBackgroundGpsEnabled(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(BG_GPS_STORAGE_KEY, String(enabled));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft ob ACCESS_BACKGROUND_LOCATION gewaehrt ist und oeffnet sonst die
|
||||||
|
* Android-App-Settings damit der User "Immer erlauben" auswaehlen kann.
|
||||||
|
* Returns true wenn permission ok, false wenn User Settings oeffnen muss. */
|
||||||
|
export async function ensureBackgroundLocationPermission(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') return true;
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.check(
|
||||||
|
'android.permission.ACCESS_BACKGROUND_LOCATION' as any,
|
||||||
|
);
|
||||||
|
if (granted) return true;
|
||||||
|
// Erst FINE_LOCATION anfordern falls noch nicht da
|
||||||
|
const fine = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
);
|
||||||
|
if (fine !== PermissionsAndroid.RESULTS.GRANTED) return false;
|
||||||
|
// Ab Android 10+ kann BACKGROUND_LOCATION NICHT ueber den normalen
|
||||||
|
// PermissionsAndroid.request abgefragt werden — User muss in Settings
|
||||||
|
// auf "Immer erlauben" wechseln. Wir oeffnen die App-Settings-Seite.
|
||||||
|
ToastAndroid.show(
|
||||||
|
'Bitte in Android-Einstellungen unter Standort "Immer erlauben" auswaehlen',
|
||||||
|
ToastAndroid.LONG,
|
||||||
|
);
|
||||||
|
Linking.openSettings();
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[gps-track] BG-Permission-Check fehlgeschlagen:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Listener = (active: boolean) => void;
|
type Listener = (active: boolean) => void;
|
||||||
|
|
||||||
@@ -26,6 +79,13 @@ class GpsTrackingService {
|
|||||||
private listeners: Set<Listener> = new Set();
|
private listeners: Set<Listener> = new Set();
|
||||||
// Defensive: nicht zu schnell oeffentlich togglen
|
// Defensive: nicht zu schnell oeffentlich togglen
|
||||||
private lastChangeAt = 0;
|
private lastChangeAt = 0;
|
||||||
|
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
|
||||||
|
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
|
||||||
|
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
|
||||||
|
// mehr triggert.
|
||||||
|
private lastLat: number | null = null;
|
||||||
|
private lastLon: number | null = null;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.active;
|
return this.active;
|
||||||
@@ -79,11 +139,21 @@ class GpsTrackingService {
|
|||||||
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
|
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Background-GPS opt-in: wenn aktiv, ForegroundService mit type=location
|
||||||
|
// hochziehen. Brauche ACCESS_BACKGROUND_LOCATION (User muss in Android-
|
||||||
|
// Settings 'Immer erlauben' aktivieren). Wenn die fehlt, watchPosition
|
||||||
|
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
|
||||||
|
const bgEnabled = await isBackgroundGpsEnabled();
|
||||||
|
if (bgEnabled) {
|
||||||
|
try { await acquireBackgroundAudio('location'); } catch {}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.watchId = Geolocation.watchPosition(
|
this.watchId = Geolocation.watchPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
const lat = pos.coords.latitude;
|
const lat = pos.coords.latitude;
|
||||||
const lon = pos.coords.longitude;
|
const lon = pos.coords.longitude;
|
||||||
|
this.lastLat = lat;
|
||||||
|
this.lastLon = lon;
|
||||||
rvs.send('location_update' as any, { lat, lon });
|
rvs.send('location_update' as any, { lat, lon });
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
@@ -96,6 +166,17 @@ class GpsTrackingService {
|
|||||||
fastestInterval: 10000, // (Android) max Frequenz
|
fastestInterval: 10000, // (Android) max Frequenz
|
||||||
} as any,
|
} as any,
|
||||||
);
|
);
|
||||||
|
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
|
||||||
|
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
|
||||||
|
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
|
||||||
|
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
|
||||||
|
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.lastLat != null && this.lastLon != null) {
|
||||||
|
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
this.active = true;
|
this.active = true;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
@@ -118,6 +199,12 @@ class GpsTrackingService {
|
|||||||
try { Geolocation.clearWatch(this.watchId); } catch {}
|
try { Geolocation.clearWatch(this.watchId); } catch {}
|
||||||
this.watchId = null;
|
this.watchId = null;
|
||||||
}
|
}
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
// Location-Foreground-Service-Slot freigeben (falls vorher acquired)
|
||||||
|
try { releaseBackgroundAudio('location'); } catch {}
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|||||||
@@ -7,10 +7,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Platform } from 'react-native';
|
import { Platform, DeviceEventEmitter } from 'react-native';
|
||||||
import rvs from './rvs';
|
import rvs from './rvs';
|
||||||
|
|
||||||
|
// Lokales Event damit die SettingsScreen Live Logs / Events Tabs
|
||||||
|
// auch das sehen was die App SELBST loggt (reportAppDebug/Error).
|
||||||
|
// Bisher gingen die nur via RVS an die Bridge. Lokal sichtbar = Mama-
|
||||||
|
// tauglich Debug ohne curl.
|
||||||
|
export const APP_LOG_EVENT = 'AriaLocalAppLog';
|
||||||
|
|
||||||
|
interface LocalLogEntry {
|
||||||
|
ts: number;
|
||||||
|
level: 'info' | 'warn' | 'error';
|
||||||
|
scope: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
||||||
|
// Eigener Toggle fuer Debug-Logs die ueber RVS an die Bridge gehen
|
||||||
|
// (/shared/logs/app.log → Diagnostic /api/app-log). Damit der Default-User
|
||||||
|
// nicht stuendlich Traffic + Disk-Schreiben hat, dieser ist DEFAULT AUS.
|
||||||
|
// Stefan schaltet's nur ein wenn er ein konkretes Problem debuggen muss.
|
||||||
|
export const DEBUG_LOGS_TO_BRIDGE_KEY = 'aria_debug_logs_to_bridge';
|
||||||
|
|
||||||
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
|
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
|
||||||
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
|
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
|
||||||
@@ -18,6 +36,7 @@ const originalLog = console.log.bind(console);
|
|||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
let _verbose = true;
|
let _verbose = true;
|
||||||
|
let _debugLogsToBridge = false;
|
||||||
|
|
||||||
function applyState(): void {
|
function applyState(): void {
|
||||||
console.log = _verbose ? originalLog : noop;
|
console.log = _verbose ? originalLog : noop;
|
||||||
@@ -29,6 +48,10 @@ export async function initLogger(): Promise<void> {
|
|||||||
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
|
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
|
||||||
_verbose = v !== 'false'; // default: true
|
_verbose = v !== 'false'; // default: true
|
||||||
} catch {}
|
} catch {}
|
||||||
|
try {
|
||||||
|
const d = await AsyncStorage.getItem(DEBUG_LOGS_TO_BRIDGE_KEY);
|
||||||
|
_debugLogsToBridge = d === 'true'; // default: false
|
||||||
|
} catch {}
|
||||||
applyState();
|
applyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +65,15 @@ export function setVerboseLogging(verbose: boolean): void {
|
|||||||
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDebugLogsToBridge(): boolean {
|
||||||
|
return _debugLogsToBridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDebugLogsToBridge(enabled: boolean): void {
|
||||||
|
_debugLogsToBridge = enabled;
|
||||||
|
AsyncStorage.setItem(DEBUG_LOGS_TO_BRIDGE_KEY, String(enabled)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
|
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
|
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
|
||||||
@@ -61,9 +93,10 @@ let _reportingInstalled = false;
|
|||||||
|
|
||||||
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||||
export function reportAppError(ev: AppErrorEvent): void {
|
export function reportAppError(ev: AppErrorEvent): void {
|
||||||
|
const ts = Date.now();
|
||||||
try {
|
try {
|
||||||
rvs.send('app_log' as any, {
|
rvs.send('app_log' as any, {
|
||||||
ts: Date.now(),
|
ts,
|
||||||
platform: Platform.OS,
|
platform: Platform.OS,
|
||||||
level: ev.level || 'error',
|
level: ev.level || 'error',
|
||||||
scope: ev.scope,
|
scope: ev.scope,
|
||||||
@@ -73,11 +106,49 @@ export function reportAppError(ev: AppErrorEvent): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// RVS noch nicht connected — Fehler geht im console weiter.
|
// RVS noch nicht connected — Fehler geht im console weiter.
|
||||||
}
|
}
|
||||||
|
// Lokal in den App-Logs-Tab emitten — Errors gehen IMMER durch
|
||||||
|
// (unabhaengig vom Debug-Toggle).
|
||||||
|
try {
|
||||||
|
const entry: LocalLogEntry = {
|
||||||
|
ts, level: ev.level || 'error', scope: ev.scope, message: ev.message,
|
||||||
|
};
|
||||||
|
DeviceEventEmitter.emit(APP_LOG_EVENT, entry);
|
||||||
|
} catch {}
|
||||||
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
|
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
|
||||||
// den Crash sieht.
|
// den Crash sieht.
|
||||||
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
|
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Schickt eine Debug-/Info-Message via RVS an die Bridge. Landet ebenfalls
|
||||||
|
* in /shared/logs/app.log — abrufbar via `curl /api/app-log?lines=N`.
|
||||||
|
* Im Gegensatz zu reportAppError: keine Stacktrace, level=info, kein
|
||||||
|
* console.error. Fuer Live-Diagnose im Hintergrund wenn ADB nicht da ist.
|
||||||
|
*
|
||||||
|
* Nur aktiv wenn Settings → Protokoll → Debug-Logs an Bridge AN ist.
|
||||||
|
* Default aus damit Mama-Modus keine Disk-Schreiblast hat. Error-Reports
|
||||||
|
* (reportAppError) gehen weiterhin IMMER durch. */
|
||||||
|
export function reportAppDebug(scope: string, message: string): void {
|
||||||
|
if (!_debugLogsToBridge) return;
|
||||||
|
const ts = Date.now();
|
||||||
|
const trimmed = String(message).slice(0, 2000);
|
||||||
|
try {
|
||||||
|
rvs.send('app_log' as any, {
|
||||||
|
ts,
|
||||||
|
platform: Platform.OS,
|
||||||
|
level: 'info',
|
||||||
|
scope,
|
||||||
|
message: trimmed,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
// Plus lokal in den App-Logs-Tab emitten — damit Stefan in der App
|
||||||
|
// selbst (Settings → Protokoll → Live Logs) sieht was passiert,
|
||||||
|
// ohne curl gegen Bridge.
|
||||||
|
try {
|
||||||
|
const entry: LocalLogEntry = { ts, level: 'info', scope, message: trimmed };
|
||||||
|
DeviceEventEmitter.emit(APP_LOG_EVENT, entry);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
|
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
|
||||||
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
|
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
|
||||||
export function installGlobalCrashReporter(): void {
|
export function installGlobalCrashReporter(): void {
|
||||||
|
|||||||
@@ -43,6 +43,42 @@ class PhoneCallService {
|
|||||||
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
||||||
* TelephonyManager-IDLE-Event kommt. */
|
* TelephonyManager-IDLE-Event kommt. */
|
||||||
private interruptedByFocus: boolean = false;
|
private interruptedByFocus: boolean = false;
|
||||||
|
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
|
||||||
|
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
|
||||||
|
private telephonyAttached: boolean = false;
|
||||||
|
|
||||||
|
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
|
||||||
|
status(): { focusAttached: boolean; telephonyAttached: boolean } {
|
||||||
|
return {
|
||||||
|
focusAttached: this.focusSubscription !== null,
|
||||||
|
telephonyAttached: this.telephonyAttached,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
|
||||||
|
* TelephonyManager-Listener verloren ging (kann passieren wenn der
|
||||||
|
* React-Bridge-Context recreated wurde), neu attachen. */
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
if (!this.started) return;
|
||||||
|
if (this.telephonyAttached) return; // alles ok
|
||||||
|
if (!PhoneCall) return;
|
||||||
|
try {
|
||||||
|
const ok = await PhoneCall.start();
|
||||||
|
if (ok) {
|
||||||
|
if (!this.subscription) {
|
||||||
|
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
||||||
|
this.subscription = emitter.addListener(
|
||||||
|
'PhoneCallStateChanged',
|
||||||
|
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.telephonyAttached = true;
|
||||||
|
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async start(): Promise<boolean> {
|
async start(): Promise<boolean> {
|
||||||
if (this.started || Platform.OS !== 'android') return false;
|
if (this.started || Platform.OS !== 'android') return false;
|
||||||
@@ -82,7 +118,10 @@ class PhoneCallService {
|
|||||||
'PhoneCallStateChanged',
|
'PhoneCallStateChanged',
|
||||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||||
);
|
);
|
||||||
|
this.telephonyAttached = true;
|
||||||
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
||||||
|
} else {
|
||||||
|
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
||||||
@@ -108,6 +147,7 @@ class PhoneCallService {
|
|||||||
this.started = false;
|
this.started = false;
|
||||||
this.lastState = 'idle';
|
this.lastState = 'idle';
|
||||||
this.interruptedByFocus = false;
|
this.interruptedByFocus = false;
|
||||||
|
this.telephonyAttached = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onStateChanged(state: PhoneState): void {
|
private _onStateChanged(state: PhoneState): void {
|
||||||
|
|||||||
@@ -83,21 +83,39 @@ class RVSConnection {
|
|||||||
|
|
||||||
// --- Verbindung ---
|
// --- Verbindung ---
|
||||||
|
|
||||||
/** Verbindung zum RVS aufbauen */
|
/** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
|
||||||
connect(): void {
|
* schliessen + neu verbinden (auch wenn JS denkt readyState=OPEN — kann
|
||||||
|
* nach Hintergrund-Pause ein Zombie-WS sein wo TCP tot ist aber JS-State
|
||||||
|
* noch OPEN zeigt; in dem Fall war "Bereits verbunden" ein No-Op und
|
||||||
|
* Stefan musste manuell zigmal klicken). */
|
||||||
|
connect(force: boolean = false): void {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
|
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (!force && this.ws?.readyState === WebSocket.OPEN) {
|
||||||
this.log('info', 'Bereits verbunden');
|
this.log('info', 'Bereits verbunden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wenn ein WS-Objekt da ist (Zombie oder lebend), sauber abreissen
|
||||||
|
// bevor wir einen neuen aufbauen — sonst gibt's zwei parallele
|
||||||
|
// Verbindungen + doppelte Events.
|
||||||
|
if (this.ws) {
|
||||||
|
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
||||||
|
try {
|
||||||
|
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
|
||||||
|
this.ws.onerror = null;
|
||||||
|
this.ws.close();
|
||||||
|
} catch (_) {}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||||
this.usingTLSFallback = false;
|
this.usingTLSFallback = false;
|
||||||
|
this.clearTimers();
|
||||||
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
|
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
|
||||||
this.establishConnection();
|
this.establishConnection();
|
||||||
}
|
}
|
||||||
@@ -212,6 +230,16 @@ class RVSConnection {
|
|||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.setState('disconnected');
|
this.setState('disconnected');
|
||||||
|
|
||||||
|
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
|
||||||
|
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
|
||||||
|
// der Hauptgrund warum die App nach Hintergrund-Rueckkehr nicht
|
||||||
|
// mehr verband — TLS-Handshake-Timeout in einem Reconnect → Fallback
|
||||||
|
// auf ws:// → Caddy refused → endlos im Fallback haengen.
|
||||||
|
if (this.usingTLSFallback) {
|
||||||
|
this.log('info', 'Reset TLS-Fallback fuer naechsten Reconnect (zurueck zu wss://)');
|
||||||
|
this.usingTLSFallback = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.shouldReconnect) {
|
if (this.shouldReconnect) {
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class UpdateService {
|
|||||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
||||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
||||||
const fileSize = await RNFS.stat(destPath);
|
const fileSize = await RNFS.stat(destPath);
|
||||||
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
||||||
|
|
||||||
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ class WakeWordService {
|
|||||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||||
* der openWakeWord faelschlich triggern kann. */
|
* der openWakeWord faelschlich triggern kann. */
|
||||||
private cooldownUntilMs: number = 0;
|
private cooldownUntilMs: number = 0;
|
||||||
|
/** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
|
||||||
|
* ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
|
||||||
|
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
|
||||||
|
* Stefan gar nicht in der App war). */
|
||||||
|
private lastTriggerAt: number = 0;
|
||||||
|
|
||||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||||
private nativeReady: boolean = false;
|
private nativeReady: boolean = false;
|
||||||
@@ -174,6 +179,8 @@ class WakeWordService {
|
|||||||
try {
|
try {
|
||||||
await OpenWakeWord.start();
|
await OpenWakeWord.start();
|
||||||
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
|
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
|
||||||
|
// Debug-Log via RVS damit wir auch ohne ADB sehen wann es greift
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.start', `armed, keyword=${this.keyword}`)).catch(()=>{});
|
||||||
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||||
this.setState('armed');
|
this.setState('armed');
|
||||||
return true;
|
return true;
|
||||||
@@ -231,14 +238,24 @@ class WakeWordService {
|
|||||||
}
|
}
|
||||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||||
this.keyword, this.state, this.bargeListening);
|
this.keyword, this.state, this.bargeListening);
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
||||||
|
`keyword=${this.keyword} state=${this.state} barge=${this.bargeListening}`)).catch(()=>{});
|
||||||
|
this.lastTriggerAt = now;
|
||||||
if (this.nativeReady && OpenWakeWord) {
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
try { await OpenWakeWord.stop(); } catch {}
|
try {
|
||||||
|
await OpenWakeWord.stop();
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect', 'native stop ok')).catch(()=>{});
|
||||||
|
} catch (e: any) {
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect', `native stop FAIL ${e?.message}`)).catch(()=>{});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.bargeListening = false;
|
this.bargeListening = false;
|
||||||
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
|
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
|
||||||
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
|
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
|
||||||
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
|
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
|
||||||
if (this.state === 'conversing') {
|
if (this.state === 'conversing') {
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
||||||
|
`barge path: cbs=${this.bargeCallbacks.length}`)).catch(()=>{});
|
||||||
this.bargeCallbacks.forEach(cb => {
|
this.bargeCallbacks.forEach(cb => {
|
||||||
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
|
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
|
||||||
});
|
});
|
||||||
@@ -246,11 +263,16 @@ class WakeWordService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState('conversing');
|
this.setState('conversing');
|
||||||
setTimeout(() => {
|
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
||||||
if (this.state === 'conversing') {
|
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
||||||
this.wakeCallbacks.forEach(cb => cb());
|
// nicht zuendekommen, weil Hermes auf einen Native-Wake-Event wartet.
|
||||||
}
|
// OpenWakeWord.stop() oben ist awaited → Mikro ist schon frei, kein
|
||||||
}, 200);
|
// 200ms-Sicherheitsabstand noetig.
|
||||||
|
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
||||||
|
`state→conversing, firing ${this.wakeCallbacks.length} callback(s) directly`)).catch(()=>{});
|
||||||
|
this.wakeCallbacks.forEach(cb => {
|
||||||
|
try { cb(); } catch (e) { console.warn('[WakeWord] wake cb err:', e); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
||||||
@@ -341,6 +363,33 @@ class WakeWordService {
|
|||||||
this.setState('off');
|
this.setState('off');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
|
||||||
|
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
|
||||||
|
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
|
||||||
|
* zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
|
||||||
|
* wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
|
||||||
|
* Returnt true wenn verworfen wurde. */
|
||||||
|
async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
|
||||||
|
if (this.state !== 'conversing') return false;
|
||||||
|
if (this.lastTriggerAt === 0) return false;
|
||||||
|
const age = Date.now() - this.lastTriggerAt;
|
||||||
|
if (age > maxAgeMs) return false;
|
||||||
|
console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
|
||||||
|
this.lastTriggerAt = 0;
|
||||||
|
if (this.nativeReady && OpenWakeWord) {
|
||||||
|
try {
|
||||||
|
await OpenWakeWord.start();
|
||||||
|
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
|
||||||
|
this.setState('armed');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState('off');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
if (this.state !== 'conversing') return;
|
if (this.state !== 'conversing') return;
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# CPU-only torch zuerst — sonst zieht sentence-transformers den Default
|
||||||
|
# torch-Wheel der ~5 GB CUDA-Libs (nvidia-cudnn, nvidia-cublas, cuda-toolkit,
|
||||||
|
# triton, ...) als Dependencies einsaugt. Brain laeuft komplett auf CPU
|
||||||
|
# (MiniLM-Embeddings ~120 MB), wir brauchen das alles nicht.
|
||||||
|
RUN pip install --no-cache-dir torch==2.5.1 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
+790
-14
@@ -18,6 +18,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from conversation import Conversation, Turn
|
from conversation import Conversation, Turn
|
||||||
@@ -27,6 +31,34 @@ from proxy_client import ProxyClient, Message as ProxyMessage
|
|||||||
import skills as skills_mod
|
import skills as skills_mod
|
||||||
import triggers as triggers_mod
|
import triggers as triggers_mod
|
||||||
import watcher as watcher_mod
|
import watcher as watcher_mod
|
||||||
|
import oauth as oauth_mod
|
||||||
|
|
||||||
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||||
|
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
|
||||||
|
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
|
||||||
|
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
|
||||||
|
FLUX_HTTP_TIMEOUT_SEC = 1200
|
||||||
|
# Diagnostic-Settings fuer FLUX (Default-Modell + User-Keywords) liegen im
|
||||||
|
# selben File wie F5-TTS/Whisper Config — von der aria-bridge geschrieben.
|
||||||
|
VOICE_CONFIG_PATH = "/shared/config/voice_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_flux_config() -> dict:
|
||||||
|
"""Liest fluxXxx-Felder aus der Voice-Config. Default-Werte wenn nichts
|
||||||
|
persistiert ist — Stefan hat in Diagnostic vielleicht noch nichts gesetzt."""
|
||||||
|
try:
|
||||||
|
with open(VOICE_CONFIG_PATH, encoding="utf-8") as f:
|
||||||
|
data = json.load(f) or {}
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Voice-Config lesen fehlgeschlagen: %s", exc)
|
||||||
|
data = {}
|
||||||
|
return {
|
||||||
|
"fluxDefaultModel": data.get("fluxDefaultModel", "dev"),
|
||||||
|
"fluxKeywordRaw": data.get("fluxKeywordRaw", "flux"),
|
||||||
|
"fluxKeywordSwitch": data.get("fluxKeywordSwitch", "fix"),
|
||||||
|
}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,6 +73,18 @@ META_TOOLS = [
|
|||||||
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
|
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
|
||||||
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
|
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
|
||||||
"mit den pip_packages die er braucht.\n\n"
|
"mit den pip_packages die er braucht.\n\n"
|
||||||
|
"PFLICHT VORHER:\n"
|
||||||
|
" - `skill_list` aufrufen und pruefen ob ein passender Skill schon "
|
||||||
|
"existiert. Wenn ja: `skill_update` statt neu anlegen.\n"
|
||||||
|
" - Name OHNE Versionssuffix waehlen (kein `-v2`, `_v3`, `-new`, "
|
||||||
|
"`-fixed`, `-aria`, `-ctl`). Versionsverwaltung ist intern, Du brauchst "
|
||||||
|
"nur einen klaren Namen.\n"
|
||||||
|
" - Bei OAuth-Services (Spotify, Google, GitHub etc.): NIEMALS "
|
||||||
|
"client_id/client_secret/Tokens in den Code schreiben. Nutze "
|
||||||
|
"`oauth_get_token('<service>')` — das macht Auto-Refresh. Sonst muss "
|
||||||
|
"Stefan sich alle 60min manuell neu einloggen.\n"
|
||||||
|
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
|
||||||
|
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
|
||||||
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
|
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
|
||||||
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
|
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
|
||||||
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
|
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
|
||||||
@@ -58,14 +102,18 @@ META_TOOLS = [
|
|||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string", "description": "kurz, kebab-case, a-z 0-9 - _"},
|
"name": {"type": "string", "description": "kurz, snake_case (NUR a-z 0-9 _). KEINE Bindestriche — die brechen das Tool-Schema beim claude-max-api-proxy. Statt 'yt-dlp-download' → 'yt_dlp_download'."},
|
||||||
"description": {"type": "string", "description": "Was kann der Skill? 1 Satz."},
|
"description": {"type": "string", "description": "Was kann der Skill? 1 Satz."},
|
||||||
"entry_code": {
|
"entry_code": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Python-Code. Args lesen via os.environ['ARG_NAME']. "
|
"Python-Code. Args lesen via os.environ['ARG_<UPPER_NAME>']. "
|
||||||
"Resultat per print() (stdout) zurueck. Bei Fehler: "
|
"WICHTIG: der Präfix `ARG_` ist Pflicht (Konvention vom "
|
||||||
"non-zero exit (sys.exit(1) o.ae.)."
|
"Skill-Runner). NIEMALS direkt PATH/METHOD/BODY etc. "
|
||||||
|
"abrufen — das sind reservierte System-ENV (PATH = "
|
||||||
|
"Executable-Suchpfad, nicht Dein arg!). Resultat per "
|
||||||
|
"print() (stdout) zurueck. Bei Fehler: non-zero exit "
|
||||||
|
"(sys.exit(1) o.ae.)."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"readme": {"type": "string", "description": "Markdown — was macht der Skill, Beispiel-Aufrufe"},
|
"readme": {"type": "string", "description": "Markdown — was macht der Skill, Beispiel-Aufrufe"},
|
||||||
@@ -92,6 +140,189 @@ META_TOOLS = [
|
|||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_update",
|
||||||
|
"description": (
|
||||||
|
"Aktualisiere einen EXISTIERENDEN Skill statt eine zweite Version "
|
||||||
|
"mit `-v2`/`-new`/`-fixed` Suffix anzulegen. Stefan hasst Skill-"
|
||||||
|
"Friedhoefe. Wenn Du `youtube2mp3` umbauen sollst → `skill_update` "
|
||||||
|
"auf den bestehenden, NICHT `skill_create` mit neuem Namen.\n\n"
|
||||||
|
"Skills sind GANZ NORMALER Python-Code. Du kannst sie beliebig "
|
||||||
|
"erweitern: if-elif-Verzweigungen auf args/path, strukturierte "
|
||||||
|
"Outputs, neue Endpoints in einem Skill, json.loads etc. "
|
||||||
|
"'Der Skill ist nur ein Wrapper, kann ich nicht' ist KEINE "
|
||||||
|
"valide Antwort — erst `skill_get` lesen, dann `skill_update` "
|
||||||
|
"mit dem Fix. Stefan ist kein Python-Entwickler, er nennt das "
|
||||||
|
"ZIEL, Du baust das WIE.\n\n"
|
||||||
|
"Du kannst gleichzeitig `entry_code` (Python-Code austauschen), "
|
||||||
|
"`readme`, `pip_packages` (bei Aenderung wird die venv automatisch "
|
||||||
|
"neu aufgebaut), `args`, `description` und `active` setzen. Felder "
|
||||||
|
"die Du weglaesst bleiben unberuehrt.\n\n"
|
||||||
|
"WENN Du Dir bei einem grundlegenden API-Bruch unsicher bist ob "
|
||||||
|
"der Skill noch zum Namen passt: lieber `skill_delete` + "
|
||||||
|
"`skill_create` mit neuem semantischen Namen statt eines "
|
||||||
|
"halbgaren Updates."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Bestehender Skill-Name"},
|
||||||
|
"entry_code": {"type": "string", "description": "Neuer Python-Code (optional)"},
|
||||||
|
"readme": {"type": "string", "description": "Neuer README-Inhalt (optional)"},
|
||||||
|
"pip_packages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Neue pip-Pakete (ueberschreibt komplette Liste; triggert venv-Rebuild)",
|
||||||
|
},
|
||||||
|
"args": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
"description": "Neues Args-Schema (optional)",
|
||||||
|
},
|
||||||
|
"description": {"type": "string", "description": "Neue Beschreibung (optional)"},
|
||||||
|
"active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
|
||||||
|
"config_schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "object"},
|
||||||
|
"description": (
|
||||||
|
"Optional neues config_schema fuer den Skill. Liste von "
|
||||||
|
"Feldern [{name, type, label, secret?, description?, default?}]. "
|
||||||
|
"type: string|number|boolean|password (password impliziert secret=true). "
|
||||||
|
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_scaffold",
|
||||||
|
"description": (
|
||||||
|
"ERSTE WAHL fuer Skill-Bau wenn das Muster zu einem Template passt — "
|
||||||
|
"Brain expandiert das Skelett, Du sparst Dir das vollstaendige "
|
||||||
|
"Python-Programm zu generieren. Wenn Stefan eine externe API "
|
||||||
|
"mehrmals nutzt: SOFORT `skill_scaffold` statt jedes Mal "
|
||||||
|
"ad-hoc Bash-curl.\n\n"
|
||||||
|
"Verfuegbare Templates:\n"
|
||||||
|
" - **oauth-api**: OAuth2-API (Spotify, GitHub, Reddit, Google, Discord, …). "
|
||||||
|
"Token kommt vom Brain mit Auto-Refresh. params: "
|
||||||
|
"`{service:'spotify', base_url?:'https://...'}`\n"
|
||||||
|
" - **apikey-api**: API mit statischem Key (OpenWeather, OpenAI, Twilio). "
|
||||||
|
"Key liegt im skill.json config_schema → CFG_<NAME> ENV. params: "
|
||||||
|
"`{api_name:'OpenWeather', key_env:'OWM_API_KEY', auth_header?:'Authorization', auth_prefix?:'Bearer ', base_url:'https://...'}`\n"
|
||||||
|
" - **file-process**: Skelett fuer Datei-In/Datei-Out (PDF, Bild, JSON umformen). "
|
||||||
|
"process()-Funktion ist Stub — danach `skill_update` mit echtem Code. params: "
|
||||||
|
"`{output_ext:'txt'}`\n\n"
|
||||||
|
"Nach Scaffold kannst Du das Skelett via `skill_update` weiter "
|
||||||
|
"anpassen falls noetig (mehr pip_packages, andere args, …). "
|
||||||
|
"Aber meistens reicht das Template direkt.\n\n"
|
||||||
|
"Wenn kein Template passt: erst pruefen ob Du wirklich ein "
|
||||||
|
"kustomes brauchst, sonst lieber Template + Update."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string",
|
||||||
|
"description": "Skill-Name (snake_case, NUR a-z 0-9 _, KEINE Bindestriche, ohne Versionssuffix)"},
|
||||||
|
"template": {"type": "string",
|
||||||
|
"enum": ["oauth-api", "apikey-api", "file-process"],
|
||||||
|
"description": "Eines der drei Templates"},
|
||||||
|
"params": {"type": "object",
|
||||||
|
"description": "Template-spezifische Parameter (siehe description)"},
|
||||||
|
},
|
||||||
|
"required": ["name", "template"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_set_config",
|
||||||
|
"description": (
|
||||||
|
"Setzt Config-Werte fuer einen Skill persistent (z.B. API-Keys, "
|
||||||
|
"User-IDs, Endpoint-URLs). Werte landen als CFG_<UPPER_NAME> ENV "
|
||||||
|
"im naechsten skill_run. Nutze das wenn Stefan dir im Chat einen "
|
||||||
|
"Wert nennt ('mein OpenWeather-Key ist abc123') — schreib den "
|
||||||
|
"NICHT in den Skill-Code, sondern hierher.\n\n"
|
||||||
|
"WICHTIG: values ueberschreibt komplett. Wenn Du nur einen Wert "
|
||||||
|
"aendern willst: erst per Diagnostic-UI oder Skill-Inspect die "
|
||||||
|
"aktuelle Liste ansehen und mit dem neuen Wert ergaenzen."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Skill-Name"},
|
||||||
|
"values": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Map config-Feldname → Wert. Felder muessen im config_schema deklariert sein.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["name", "values"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_list_versions",
|
||||||
|
"description": (
|
||||||
|
"Listet archivierte Versionen eines Skills (jeder skill_update "
|
||||||
|
"legt automatisch eine an). Returns [{version_id, archived_at, "
|
||||||
|
"summary}]. Brauchst Du fuer skill_rollback."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"}},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_rollback",
|
||||||
|
"description": (
|
||||||
|
"Stellt eine fruehere Skill-Version wieder her. Vor dem Rollback "
|
||||||
|
"wird der aktuelle Stand automatisch archiviert — du verlierst "
|
||||||
|
"nichts. Nutze das wenn ein skill_update was kaputt gemacht hat "
|
||||||
|
"oder Stefan sagt 'mach den letzten Stand wieder her'. "
|
||||||
|
"version_id bekommst Du aus skill_list_versions."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"version_id": {"type": "string", "description": "Format v_<timestamp>"},
|
||||||
|
},
|
||||||
|
"required": ["name", "version_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "skill_delete",
|
||||||
|
"description": (
|
||||||
|
"Loescht einen Skill samt venv und Logs. Nutze das wenn:\n"
|
||||||
|
"1. Stefan explizit sagt der Skill soll weg\n"
|
||||||
|
"2. Du eine alte Skill-Version losgeworden bist nachdem `skill_create` "
|
||||||
|
"mit besserem Namen erfolgreich war (Aufraeumen statt Skill-Friedhof)\n"
|
||||||
|
"3. Ein Skill grundlegend kaputt und ein Update sich nicht mehr lohnt — "
|
||||||
|
"in dem Fall bestaetige vorher kurz bei Stefan.\n\n"
|
||||||
|
"Nicht rueckholbar."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"}},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -134,10 +365,19 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "trigger_watcher",
|
"name": "trigger_watcher",
|
||||||
"description": (
|
"description": (
|
||||||
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
|
"Lege einen Watcher-Trigger an — pollt eine Condition, "
|
||||||
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
||||||
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
||||||
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
|
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
|
||||||
|
"Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
|
||||||
|
"- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
|
||||||
|
"Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
|
||||||
|
"- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
|
||||||
|
"Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
|
||||||
|
"wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
|
||||||
|
"nur gegen GPS-Jitter).\n"
|
||||||
|
"- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
|
||||||
|
"Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -206,6 +446,219 @@ META_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "oauth_register_provider",
|
||||||
|
"description": (
|
||||||
|
"Registriert einen NEUEN OAuth2-Provider in oauth_apps.json — "
|
||||||
|
"nutze das wenn Stefan einen Service nutzen will, der noch nicht "
|
||||||
|
"in der Default-Liste (spotify, google, github, strava, microsoft) "
|
||||||
|
"ist. Du kennst typische OAuth-Endpunkte aus deinem Training "
|
||||||
|
"(Dropbox, Twitch, Discord, Slack, Reddit, LinkedIn, Notion, "
|
||||||
|
"Zoom, Trello, ...). Trag NUR die URLs ein — client_id / "
|
||||||
|
"client_secret bleiben Stefans Job (Diagnostic > OAuth-Apps oder "
|
||||||
|
"App > Settings > OAuth-Apps).\n\n"
|
||||||
|
"**Workflow bei neuem Service:**\n"
|
||||||
|
"1. `oauth_register_provider` mit auth_url + token_url + scopes\n"
|
||||||
|
"2. Sag Stefan: \"Service '{name}' ist eingerichtet. Trag in "
|
||||||
|
"Diagnostic/App > OAuth-Apps deine client_id + client_secret aus "
|
||||||
|
"dem {name}-Developer-Dashboard ein. Plus die Callback-URL "
|
||||||
|
"{callback} musst Du dort einmal als Redirect-URI eintragen.\"\n"
|
||||||
|
"3. Warten bis Stefan fertig ist\n"
|
||||||
|
"4. `oauth_authorize` rufen\n\n"
|
||||||
|
"**`client_auth`-Wert:** Die meisten Provider wollen client_id+"
|
||||||
|
"secret im Body (`body`, default). Spotify und manche andere "
|
||||||
|
"wollen Basic-Auth-Header (`basic`). Wenn du unsicher bist, "
|
||||||
|
"nimm `body` — schlaegt der Token-Request dann mit 401 fehl, "
|
||||||
|
"switch auf `basic`.\n\n"
|
||||||
|
"Bei Provider die du wirklich nicht kennst: frag Stefan oder "
|
||||||
|
"such die Docs raus statt zu raten."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service-Name (a-z 0-9 _ -, kurz, z.B. 'dropbox', 'discord')",
|
||||||
|
},
|
||||||
|
"auth_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Authorize-Endpoint, z.B. 'https://www.dropbox.com/oauth2/authorize'",
|
||||||
|
},
|
||||||
|
"token_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Token-Endpoint, z.B. 'https://api.dropboxapi.com/oauth2/token'",
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Default-Scopes die der User beim Auth zustimmen muss",
|
||||||
|
},
|
||||||
|
"client_auth": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["body", "basic"],
|
||||||
|
"description": "Wie der Provider client_id/secret erwartet (Default 'body')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["service", "auth_url", "token_url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "oauth_authorize",
|
||||||
|
"description": (
|
||||||
|
"Startet einen OAuth2-Authorize-Flow fuer einen externen "
|
||||||
|
"Service (Spotify, Google, GitHub, Strava, Microsoft, ...). "
|
||||||
|
"Returnt eine URL die Stefan im Browser oeffnen muss — er "
|
||||||
|
"loggt sich beim Provider ein und stimmt den Scopes zu, der "
|
||||||
|
"Provider redirected zu unserem RVS-Callback, RVS forwarded "
|
||||||
|
"an Brain, Token wird automatisch gespeichert.\n\n"
|
||||||
|
"**Nutze das wenn:** Stefan moechte einen Service nutzen "
|
||||||
|
"(z.B. \"verbinde mich mit Spotify\", \"baue einen Spotify-"
|
||||||
|
"Skill\"), aber `oauth_get_token` wirft *Kein Token gespeichert*.\n\n"
|
||||||
|
"**Workflow:**\n"
|
||||||
|
"1. `oauth_authorize(service='spotify')` -> URL\n"
|
||||||
|
"2. Gib Stefan die URL als anklickbaren Link\n"
|
||||||
|
"3. Warte bis er sagt dass er autorisiert hat\n"
|
||||||
|
"4. `oauth_get_token('spotify')` -> access_token, kannst Du im API-Call nutzen\n\n"
|
||||||
|
"Voraussetzung: Stefan hat in Diagnostic > OAuth-Apps fuer den "
|
||||||
|
"Service `client_id` + `client_secret` eingetragen. Falls nicht, "
|
||||||
|
"wirft das Tool eine entsprechende Fehlermeldung — sage Stefan "
|
||||||
|
"er soll das machen, NICHT versuchen die Credentials selbst zu "
|
||||||
|
"raten oder zu generieren."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service-Name. Vordefinierte: spotify, google, github, strava, microsoft. Custom-Services moeglich wenn Stefan sie in oauth_apps.json eingetragen hat (mit auth_url + token_url).",
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional: Provider-spezifische Scopes (z.B. fuer Spotify ['user-read-playback-state','playlist-modify-public']). Wenn weggelassen, werden die Default-Scopes des Services genutzt.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["service"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "oauth_get_token",
|
||||||
|
"description": (
|
||||||
|
"Liefert das aktuelle access_token fuer einen Service. "
|
||||||
|
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
|
||||||
|
"und der Provider einen refresh_token mitgegeben hat.\n\n"
|
||||||
|
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
|
||||||
|
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
|
||||||
|
"z.B. `Authorization: Bearer <token>`.\n\n"
|
||||||
|
"Wirft wenn Service noch nicht authentifiziert ist oder der "
|
||||||
|
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {"type": "string", "description": "z.B. spotify, google, ..."},
|
||||||
|
},
|
||||||
|
"required": ["service"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "oauth_revoke",
|
||||||
|
"description": (
|
||||||
|
"Loescht das gespeicherte Token fuer einen Service (lokal). "
|
||||||
|
"Stefan muss danach via `oauth_authorize` neu autorisieren wenn "
|
||||||
|
"er den Service wieder nutzen will. Nutze das wenn Stefan sagt "
|
||||||
|
"\"melde mich bei X ab\" oder \"vergiss meine Spotify-Anmeldung\"."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"service": {"type": "string"}},
|
||||||
|
"required": ["service"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "flux_generate",
|
||||||
|
"description": (
|
||||||
|
"Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. "
|
||||||
|
"Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
|
||||||
|
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
|
||||||
|
"kurz an, dann ist er nicht ueberrascht.\n\n"
|
||||||
|
"**Schreibe deine Antwort wie immer auf Deutsch**, und referenziere das "
|
||||||
|
"fertige Bild MIT dem `[FILE: ...]`-Marker, GENAU im Pfad-Format das das "
|
||||||
|
"Tool zurueckgibt. Beispiel:\n"
|
||||||
|
" 'Hier dein Aquarell:\\n[FILE: /shared/uploads/aria_generated_1234.png]'\n\n"
|
||||||
|
"Der Marker wird beim App-Renderer ausgeblendet und das Bild stattdessen "
|
||||||
|
"inline als Anhang gezeigt.\n\n"
|
||||||
|
"**Prompt-Sprache: bevorzugt Englisch.** FLUX versteht zwar Deutsch, "
|
||||||
|
"liefert aber mit englischen Prompts deutlich konsistentere Ergebnisse. "
|
||||||
|
"Uebersetze Stefans deutsche Beschreibung selbststaendig — AUSSER `raw=true`.\n\n"
|
||||||
|
"**Modus `raw=true` (Pipe-Modus):** Wenn Stefan das Raw-Keyword aus dem "
|
||||||
|
"FLUX-Settings-Block im System-Prompt nutzt (typischerweise `flux`), "
|
||||||
|
"leite seinen Text 1:1 als prompt durch — KEIN Uebersetzen, KEIN "
|
||||||
|
"Beautify, KEINE Qualitaets-Keywords. Stefan formuliert dann selbst und "
|
||||||
|
"der Prompt geht roh an FLUX. Brauchbar wenn er den vollen Output ohne "
|
||||||
|
"ARIAs Filter haben will.\n\n"
|
||||||
|
"**Modell-Wahl (`model`):** \n"
|
||||||
|
"- `default` (oder weglassen): das in den Diagnostic-Settings eingestellte "
|
||||||
|
"Default-Modell (steht im FLUX-Block im System-Prompt).\n"
|
||||||
|
"- `dev`: hochqualitatives FLUX.1-dev, 20-90s, ~28 steps.\n"
|
||||||
|
"- `schnell`: FLUX.1-schnell, 4-step distillation, ~5-15s.\n"
|
||||||
|
"Wenn Stefan das Switch-Keyword (steht ebenfalls im FLUX-Block) im Prompt "
|
||||||
|
"verwendet → setze `model` auf das ANDERE Modell als das Default. Bei "
|
||||||
|
"'in hoher Qualitaet'/'detailliert' → `dev`. Bei 'schnell mal'/'fix' → `schnell`.\n\n"
|
||||||
|
"Modell-Switch kostet einmalig 15-30s (Pipeline-Reload aus HF-Cache). "
|
||||||
|
"Stefan sieht den Status im Diagnostic-Banner.\n\n"
|
||||||
|
"Caps:\n"
|
||||||
|
"- `width`/`height`: 256-1536, wird auf Vielfache von 64 gesnappt (Default 1024)\n"
|
||||||
|
"- `steps`: 1-50 (Default 28 fuer dev, 4 fuer schnell)\n"
|
||||||
|
"- `guidance_scale`: 0.0-20.0 (Default 3.5)\n"
|
||||||
|
"- `seed`: optional, gleicher seed + gleicher prompt → gleiches Bild"
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Bei raw=false (Default): englischer Bild-Prompt, von dir aus Stefans Worten gebaut, "
|
||||||
|
"mit Stil/Licht/Kamera-Stichworten. Bei raw=true: Stefans Text 1:1 ohne Aenderung."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"raw": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": (
|
||||||
|
"true = Pipe-Modus, kein Rewriting. Setzen wenn Stefan das Raw-Keyword "
|
||||||
|
"(siehe FLUX-Block im System-Prompt) am Anfang seiner Nachricht verwendet."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["default", "dev", "schnell"],
|
||||||
|
"description": "Default-Modell oder explizit dev/schnell. Default = Diagnostic-Setting.",
|
||||||
|
},
|
||||||
|
"width": {"type": "integer", "description": "Breite in px (Default 1024, max 1536)"},
|
||||||
|
"height": {"type": "integer", "description": "Hoehe in px (Default 1024, max 1536)"},
|
||||||
|
"steps": {"type": "integer", "description": "Inference-Steps (Default 28, max 50). Mehr = besser+langsamer."},
|
||||||
|
"guidance_scale": {"type": "number", "description": "Wie strikt am Prompt kleben (Default 3.5)"},
|
||||||
|
"seed": {"type": "integer", "description": "Reproduzierbarkeits-Seed (optional)"},
|
||||||
|
},
|
||||||
|
"required": ["prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -346,10 +799,18 @@ def _skill_to_tool(s: dict) -> dict:
|
|||||||
}
|
}
|
||||||
if a.get("required"):
|
if a.get("required"):
|
||||||
required.append(name)
|
required.append(name)
|
||||||
|
# Tool-Namen duerfen in der Anthropic/Claude tool_use-API nur
|
||||||
|
# [a-zA-Z0-9_-]{1,64} sein, aber der claude-max-api-proxy (OpenAI-
|
||||||
|
# Format-Adapter) ist restriktiver und faellt bei Bindestrichen auf
|
||||||
|
# die Nase — die GANZE Tool-Liste wird dann verworfen und ARIA
|
||||||
|
# bekommt "No such tool available". Skill-Namen wie 'yt-dlp-download'
|
||||||
|
# oder 'pdf-umfrage-generator' muessen daher zu run_yt_dlp_download
|
||||||
|
# bzw. run_pdf_umfrage_generator gemappt werden.
|
||||||
|
safe_name = "run_" + re.sub(r"[^a-zA-Z0-9_]", "_", s["name"])
|
||||||
return {
|
return {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": f"run_{s['name']}",
|
"name": safe_name,
|
||||||
"description": s.get("description", "(ohne Beschreibung)"),
|
"description": s.get("description", "(ohne Beschreibung)"),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -428,10 +889,26 @@ class Agent:
|
|||||||
condition_funcs = watcher_mod.describe_functions()
|
condition_funcs = watcher_mod.describe_functions()
|
||||||
|
|
||||||
# 5. System-Prompt + Window-Messages
|
# 5. System-Prompt + Window-Messages
|
||||||
|
flux_config = _load_flux_config()
|
||||||
|
# OAuth-Block: aktuelle Service-States + Callback-URL fuer ARIA
|
||||||
|
try:
|
||||||
|
oauth_services = oauth_mod.list_services()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("oauth list_services fehlgeschlagen: %s", exc)
|
||||||
|
oauth_services = None
|
||||||
|
oauth_host = os.environ.get("RVS_HOST", "").strip()
|
||||||
|
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
|
||||||
|
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
|
||||||
|
|
||||||
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
||||||
triggers=all_triggers,
|
triggers=all_triggers,
|
||||||
condition_vars=condition_vars,
|
condition_vars=condition_vars,
|
||||||
condition_funcs=condition_funcs)
|
condition_funcs=condition_funcs,
|
||||||
|
flux_config=flux_config,
|
||||||
|
oauth_services=oauth_services,
|
||||||
|
oauth_callback_host=oauth_host,
|
||||||
|
oauth_callback_port=oauth_port,
|
||||||
|
oauth_callback_tls=oauth_tls)
|
||||||
messages = [ProxyMessage(role="system", content=system_prompt)]
|
messages = [ProxyMessage(role="system", content=system_prompt)]
|
||||||
for t in self.conversation.window():
|
for t in self.conversation.window():
|
||||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||||
@@ -440,8 +917,14 @@ class Agent:
|
|||||||
len(hot), len(cold), len(active_skills), len(all_skills),
|
len(hot), len(cold), len(active_skills), len(all_skills),
|
||||||
len(self.conversation.window()), len(system_prompt))
|
len(self.conversation.window()), len(system_prompt))
|
||||||
|
|
||||||
# 6. Tool-Use-Loop
|
# 6. Tool-Use-Loop. Bei Exception (z.B. Proxy-Timeout) muss ein
|
||||||
|
# Assistant-Turn als Error-Marker geschrieben werden — der User-Turn
|
||||||
|
# ist bereits in der Conversation. Ohne Gegenpart wird die naechste
|
||||||
|
# Anfrage im Window an Claude geschickt mit user → user als letzten
|
||||||
|
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
|
||||||
|
# zu 400-Errors fuehren kann.
|
||||||
final_reply = ""
|
final_reply = ""
|
||||||
|
try:
|
||||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||||
result = self.proxy.chat_full(messages, tools=tools)
|
result = self.proxy.chat_full(messages, tools=tools)
|
||||||
if result.tool_calls:
|
if result.tool_calls:
|
||||||
@@ -475,6 +958,19 @@ class Agent:
|
|||||||
if not final_reply:
|
if not final_reply:
|
||||||
raise RuntimeError("Leerer Reply vom Proxy")
|
raise RuntimeError("Leerer Reply vom Proxy")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
|
||||||
|
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
|
||||||
|
# Error-Marker statt zu rollback-en (rollback wuerde Race-Conditions
|
||||||
|
# mit der JSONL-Persistenz aufmachen).
|
||||||
|
err_text = f"[Fehler: {exc}]"
|
||||||
|
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
|
||||||
|
try:
|
||||||
|
self.conversation.add("assistant", err_text)
|
||||||
|
except Exception as add_exc:
|
||||||
|
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
|
||||||
|
raise
|
||||||
|
|
||||||
# 7. Assistant-Turn (final reply) in die Conversation
|
# 7. Assistant-Turn (final reply) in die Conversation
|
||||||
self.conversation.add("assistant", final_reply)
|
self.conversation.add("assistant", final_reply)
|
||||||
return final_reply
|
return final_reply
|
||||||
@@ -496,6 +992,7 @@ class Agent:
|
|||||||
readme=arguments.get("readme", ""),
|
readme=arguments.get("readme", ""),
|
||||||
args=arguments.get("args", []),
|
args=arguments.get("args", []),
|
||||||
pip_packages=arguments.get("pip_packages", []),
|
pip_packages=arguments.get("pip_packages", []),
|
||||||
|
config_schema=arguments.get("config_schema") or None,
|
||||||
author="aria",
|
author="aria",
|
||||||
)
|
)
|
||||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||||
@@ -510,6 +1007,35 @@ class Agent:
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})."
|
return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})."
|
||||||
|
if name == "skill_scaffold":
|
||||||
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
|
template = (arguments.get("template") or "").strip()
|
||||||
|
params = arguments.get("params") or {}
|
||||||
|
if not skill_name or not template:
|
||||||
|
return "FEHLER: name + template erforderlich."
|
||||||
|
try:
|
||||||
|
manifest = skills_mod.scaffold_skill(
|
||||||
|
name=skill_name, template=template, params=params, author="aria",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
# Side-Channel-Event analog zu skill_create
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "skill_created",
|
||||||
|
"skill": {
|
||||||
|
"name": manifest["name"],
|
||||||
|
"description": manifest.get("description", ""),
|
||||||
|
"execution": manifest.get("execution", ""),
|
||||||
|
"active": manifest.get("active", True),
|
||||||
|
"setup_error": manifest.get("setup_error"),
|
||||||
|
"scaffolded_from": template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
f"OK — Skill '{manifest['name']}' aus Template '{template}' angelegt. "
|
||||||
|
f"active={manifest['active']}. "
|
||||||
|
f"Falls noetig: skill_update fuer custom Code, skill_set_config fuer secrets."
|
||||||
|
)
|
||||||
if name == "skill_list":
|
if name == "skill_list":
|
||||||
items = skills_mod.list_skills(active_only=False)
|
items = skills_mod.list_skills(active_only=False)
|
||||||
if not items:
|
if not items:
|
||||||
@@ -518,15 +1044,128 @@ class Agent:
|
|||||||
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
||||||
for s in items
|
for s in items
|
||||||
)
|
)
|
||||||
|
if name == "skill_update":
|
||||||
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
|
if not skill_name:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
patch: dict = {}
|
||||||
|
for k in ("entry_code", "readme", "description", "args", "active"):
|
||||||
|
if k in arguments and arguments[k] is not None:
|
||||||
|
patch[k] = arguments[k]
|
||||||
|
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||||
|
patch["pip_packages"] = arguments["pip_packages"]
|
||||||
|
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||||
|
patch["config_schema"] = arguments["config_schema"]
|
||||||
|
if not patch:
|
||||||
|
return "FEHLER: keine Felder zum Update angegeben."
|
||||||
|
try:
|
||||||
|
manifest = skills_mod.update_skill(skill_name, patch)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
# Side-Channel-Event als skill_created getarnt — gleiche Bubble-Mechanik
|
||||||
|
# in App/Diagnostic; das Update soll fuer Stefan ebenfalls sichtbar werden.
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "skill_created",
|
||||||
|
"skill": {
|
||||||
|
"name": manifest["name"],
|
||||||
|
"description": manifest.get("description", ""),
|
||||||
|
"execution": manifest.get("execution", ""),
|
||||||
|
"active": manifest.get("active", True),
|
||||||
|
"setup_error": manifest.get("setup_error"),
|
||||||
|
"updated": True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
changed = ", ".join(sorted(patch.keys()))
|
||||||
|
return f"OK — Skill '{skill_name}' aktualisiert ({changed}). active={manifest['active']}"
|
||||||
|
if name == "skill_delete":
|
||||||
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
|
if not skill_name:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
try:
|
||||||
|
skills_mod.delete_skill(skill_name)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
return f"OK — Skill '{skill_name}' geloescht."
|
||||||
|
if name == "skill_set_config":
|
||||||
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
|
values = arguments.get("values")
|
||||||
|
if not skill_name or not isinstance(values, dict):
|
||||||
|
return "FEHLER: name + values (dict) erforderlich."
|
||||||
|
try:
|
||||||
|
skills_mod.set_skill_config(skill_name, values)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
masked = skills_mod.get_skill_config_masked(skill_name)
|
||||||
|
return (
|
||||||
|
f"OK — Config fuer Skill '{skill_name}' gesetzt. "
|
||||||
|
f"Aktuelle Werte (secrets gemasked): {masked}"
|
||||||
|
)
|
||||||
|
if name == "skill_list_versions":
|
||||||
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
|
if not skill_name:
|
||||||
|
return "FEHLER: name ist Pflicht."
|
||||||
|
versions = skills_mod.list_skill_versions(skill_name)
|
||||||
|
if not versions:
|
||||||
|
return f"Skill '{skill_name}' hat keine archivierten Versionen."
|
||||||
|
lines = [
|
||||||
|
f"- {v.get('version_id')} ({v.get('archived_at','?')}) {v.get('summary','')}"
|
||||||
|
for v in versions
|
||||||
|
]
|
||||||
|
return "Versionen (neueste zuerst):\n" + "\n".join(lines)
|
||||||
|
if name == "skill_rollback":
|
||||||
|
skill_name = (arguments.get("name") or "").strip()
|
||||||
|
version_id = (arguments.get("version_id") or "").strip()
|
||||||
|
if not skill_name or not version_id:
|
||||||
|
return "FEHLER: name + version_id erforderlich."
|
||||||
|
try:
|
||||||
|
res = skills_mod.rollback_skill(skill_name, version_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
# Side-Channel-Event als skill_created getarnt — App/Diagnostic
|
||||||
|
# zeigen Rollback dann als sichtbare Aktion an
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "skill_created",
|
||||||
|
"skill": {
|
||||||
|
"name": skill_name,
|
||||||
|
"description": "(rollback)",
|
||||||
|
"execution": "local-venv",
|
||||||
|
"active": True,
|
||||||
|
"updated": True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
f"OK — Skill '{skill_name}' auf '{version_id}' zurueckgerollt. "
|
||||||
|
f"Sicherheits-Snapshot des vorherigen Stands: {res.get('safety_snapshot')}"
|
||||||
|
)
|
||||||
if name.startswith("run_"):
|
if name.startswith("run_"):
|
||||||
skill_name = name[len("run_"):]
|
# Tool-Namen sind 'safe' (nur _), Skill-Namen koennen aber
|
||||||
|
# Bindestriche enthalten (z.B. yt-dlp-download). Wir suchen
|
||||||
|
# zuerst exakt, dann ueber Underscore-zu-Bindestrich-Mapping.
|
||||||
|
tool_suffix = name[len("run_"):]
|
||||||
|
skill_name = tool_suffix
|
||||||
|
if skills_mod.read_manifest(skill_name) is None:
|
||||||
|
# ggf. Bindestriche zurueckmappen
|
||||||
|
for cand in skills_mod.list_skills(active_only=False):
|
||||||
|
cand_name = cand.get("name") or ""
|
||||||
|
if re.sub(r"[^a-zA-Z0-9_]", "_", cand_name) == tool_suffix:
|
||||||
|
skill_name = cand_name
|
||||||
|
break
|
||||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||||
snippet = (res.get("stdout") or "")[:2000] or "(kein stdout)"
|
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
|
||||||
err = (res.get("stderr") or "")[:500]
|
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA
|
||||||
|
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte
|
||||||
|
# KB Context, 50 KB pro Tool-Result sind locker drin.
|
||||||
|
stdout = (res.get("stdout") or "")
|
||||||
|
stderr = (res.get("stderr") or "")
|
||||||
|
if len(stdout) > 50000:
|
||||||
|
stdout = stdout[:50000] + f"\n...(abgeschnitten, original {len(res.get('stdout',''))} bytes)"
|
||||||
|
if len(stderr) > 4000:
|
||||||
|
stderr = stderr[:4000] + f"\n...(abgeschnitten)"
|
||||||
|
snippet = stdout or "(kein stdout)"
|
||||||
marker = "OK" if res["ok"] else f"FEHLER (exit={res['exit_code']})"
|
marker = "OK" if res["ok"] else f"FEHLER (exit={res['exit_code']})"
|
||||||
out = f"{marker} · {res['duration_sec']}s\nstdout:\n{snippet}"
|
out = f"{marker} · {res['duration_sec']}s\nstdout:\n{snippet}"
|
||||||
if err:
|
if stderr:
|
||||||
out += f"\nstderr:\n{err}"
|
out += f"\nstderr:\n{stderr}"
|
||||||
return out
|
return out
|
||||||
if name == "trigger_timer":
|
if name == "trigger_timer":
|
||||||
fires_at_iso = arguments.get("fires_at")
|
fires_at_iso = arguments.get("fires_at")
|
||||||
@@ -598,6 +1237,143 @@ class Agent:
|
|||||||
else:
|
else:
|
||||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
if name == "oauth_register_provider":
|
||||||
|
svc = (arguments.get("service") or "").strip()
|
||||||
|
auth_url = (arguments.get("auth_url") or "").strip()
|
||||||
|
token_url = (arguments.get("token_url") or "").strip()
|
||||||
|
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
|
||||||
|
client_auth = (arguments.get("client_auth") or "body").strip().lower()
|
||||||
|
if not svc or not auth_url or not token_url:
|
||||||
|
return "FEHLER: service, auth_url, token_url sind Pflicht."
|
||||||
|
try:
|
||||||
|
entry = oauth_mod.register_provider(
|
||||||
|
svc, auth_url, token_url, scopes=scopes, client_auth=client_auth,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("oauth_register_provider fehlgeschlagen")
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
cb = oauth_mod._callback_url(svc) if os.environ.get("RVS_HOST") else f"<RVS_HOST nicht gesetzt>/oauth/callback/{svc}"
|
||||||
|
scopes_str = ", ".join(entry.get("scopes") or []) or "(keine)"
|
||||||
|
return (
|
||||||
|
f"OK — Provider '{svc}' registriert.\n"
|
||||||
|
f" auth_url: {entry['auth_url']}\n"
|
||||||
|
f" token_url: {entry['token_url']}\n"
|
||||||
|
f" scopes: {scopes_str}\n"
|
||||||
|
f" client_auth: {entry['client_auth']}\n\n"
|
||||||
|
f"Sage Stefan: Trag in Diagnostic > OAuth-Apps (oder App > "
|
||||||
|
f"Settings > OAuth-Apps) deine client_id + client_secret aus "
|
||||||
|
f"dem {svc}-Developer-Dashboard ein. Plus die Callback-URL "
|
||||||
|
f"`{cb}` musst Du dort einmal als Redirect-URI registrieren.\n"
|
||||||
|
f"Sobald Stefan das gemacht hat, rufe `oauth_authorize` auf."
|
||||||
|
)
|
||||||
|
if name == "oauth_authorize":
|
||||||
|
svc = (arguments.get("service") or "").strip()
|
||||||
|
if not svc:
|
||||||
|
return "FEHLER: service ist Pflicht (z.B. 'spotify')."
|
||||||
|
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
|
||||||
|
try:
|
||||||
|
info = oauth_mod.build_authorize_url(svc, scopes=scopes)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("oauth_authorize fehlgeschlagen")
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
return (
|
||||||
|
f"OK — Authorize-URL fuer {svc} bereit.\n"
|
||||||
|
f"Sage Stefan: Klicke diesen Link um Dich bei {svc} anzumelden:\n\n"
|
||||||
|
f"{info['url']}\n\n"
|
||||||
|
f"Nach Zustimmung schickt Dich der Provider zu unserem Callback "
|
||||||
|
f"({info['redirect_uri']}); RVS schnappt sich den code automatisch, "
|
||||||
|
f"Brain tauscht ihn gegen ein Token. Du musst nichts copy-pasten.\n"
|
||||||
|
f"Falls beim Provider 'redirect_uri_mismatch' auftaucht, muss Stefan "
|
||||||
|
f"`{info['redirect_uri']}` einmalig im Provider-Dashboard als gueltige "
|
||||||
|
f"Redirect-URI eintragen."
|
||||||
|
)
|
||||||
|
if name == "oauth_get_token":
|
||||||
|
svc = (arguments.get("service") or "").strip()
|
||||||
|
if not svc:
|
||||||
|
return "FEHLER: service ist Pflicht."
|
||||||
|
try:
|
||||||
|
record = oauth_mod.get_token(svc)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return f"FEHLER: {exc}"
|
||||||
|
tok = record.get("access_token", "")
|
||||||
|
ttype = record.get("token_type", "Bearer")
|
||||||
|
exp = record.get("expires_at", 0)
|
||||||
|
remain = max(0, int(exp) - int(__import__("time").time()))
|
||||||
|
return (
|
||||||
|
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
|
||||||
|
f"access_token: {tok}\n"
|
||||||
|
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
|
||||||
|
)
|
||||||
|
if name == "oauth_revoke":
|
||||||
|
svc = (arguments.get("service") or "").strip()
|
||||||
|
if not svc:
|
||||||
|
return "FEHLER: service ist Pflicht."
|
||||||
|
ok = oauth_mod.revoke(svc)
|
||||||
|
return f"OK — Token fuer {svc} entfernt." if ok else f"Kein Token fuer {svc} vorhanden."
|
||||||
|
if name == "flux_generate":
|
||||||
|
prompt = (arguments.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return "FEHLER: prompt ist Pflicht."
|
||||||
|
req: dict = {"prompt": prompt}
|
||||||
|
for key in ("width", "height", "steps", "seed"):
|
||||||
|
if key in arguments and arguments[key] is not None:
|
||||||
|
try:
|
||||||
|
req[key] = int(arguments[key])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if arguments.get("guidance_scale") is not None:
|
||||||
|
try:
|
||||||
|
req["guidance_scale"] = float(arguments["guidance_scale"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
# Modell-Wahl: 'default' (oder weglassen) → flux-bridge nimmt Diagnostic-Default.
|
||||||
|
# 'dev' / 'schnell' → expliziter Override.
|
||||||
|
model_arg = (arguments.get("model") or "").strip().lower()
|
||||||
|
if model_arg in ("dev", "schnell"):
|
||||||
|
req["model"] = model_arg
|
||||||
|
# `raw` ist Brain-Domain (kein Rewriting des prompt) und wird hier
|
||||||
|
# nicht durchgereicht — der prompt enthaelt bei raw=true bereits
|
||||||
|
# Stefans Originaltext.
|
||||||
|
try:
|
||||||
|
body = json.dumps(req).encode("utf-8")
|
||||||
|
http_req = urllib.request.Request(
|
||||||
|
f"{BRIDGE_URL}/internal/flux-generate", data=body, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(http_req, timeout=FLUX_HTTP_TIMEOUT_SEC) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
result = json.loads(raw.decode("utf-8", "ignore"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
try:
|
||||||
|
err_body = exc.read().decode("utf-8", "ignore")
|
||||||
|
err_data = json.loads(err_body)
|
||||||
|
err = err_data.get("error") or err_body
|
||||||
|
except Exception:
|
||||||
|
err = str(exc)
|
||||||
|
return f"FEHLER (flux-bridge): {err}"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("flux_generate HTTP-Call fehlgeschlagen")
|
||||||
|
return f"FEHLER: flux-bridge nicht erreichbar ({exc})"
|
||||||
|
|
||||||
|
if not result.get("ok"):
|
||||||
|
return f"FEHLER (flux-bridge): {result.get('error', 'unbekannt')}"
|
||||||
|
# Kompakte Rueckmeldung: Pfad + Render-Stats. Brain bettet den
|
||||||
|
# Pfad in ihre Antwort als [FILE: ...]-Marker ein (siehe Tool-Beschreibung).
|
||||||
|
return (
|
||||||
|
f"OK — Bild generiert.\n"
|
||||||
|
f"path: {result['path']}\n"
|
||||||
|
f"size: {result.get('width','?')}x{result.get('height','?')} "
|
||||||
|
f"({result.get('sizeBytes',0)//1024} KB)\n"
|
||||||
|
f"steps={result.get('steps','?')} guidance={result.get('guidance','?')} "
|
||||||
|
f"seed={result.get('seed','?')} model={result.get('model','?')}\n"
|
||||||
|
f"renderSeconds={result.get('renderSeconds','?')}\n\n"
|
||||||
|
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
|
||||||
|
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
|
||||||
|
)
|
||||||
if name == "memory_search":
|
if name == "memory_search":
|
||||||
query = (arguments.get("query") or "").strip()
|
query = (arguments.get("query") or "").strip()
|
||||||
if not query:
|
if not query:
|
||||||
|
|||||||
+68
-19
@@ -27,7 +27,12 @@ import watcher as watcher_mod
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TICK_SEC = 30
|
# Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt
|
||||||
|
# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett
|
||||||
|
# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h
|
||||||
|
# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig
|
||||||
|
# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm.
|
||||||
|
TICK_SEC = 8
|
||||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _tick(agent_factory) -> None:
|
async def _tick(agent_factory) -> None:
|
||||||
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
|
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
|
||||||
|
|
||||||
|
near()-State-Tracking: entered_near/left_near brauchen die Information
|
||||||
|
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
|
||||||
|
Wir halten das pro Trigger als near_states-Dict im Manifest und
|
||||||
|
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
|
||||||
try:
|
try:
|
||||||
all_triggers = triggers_mod.list_triggers(active_only=True)
|
all_triggers = triggers_mod.list_triggers(active_only=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
|
|||||||
if not all_triggers:
|
if not all_triggers:
|
||||||
return
|
return
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
|
|
||||||
try:
|
|
||||||
vars_ = watcher_mod.collect_variables()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("collect_variables: %s", e)
|
|
||||||
vars_ = {}
|
|
||||||
|
|
||||||
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
|
|
||||||
# damit der Check-Interval respektiert wird)
|
|
||||||
for t in all_triggers:
|
|
||||||
if t.get("type") == "watcher":
|
|
||||||
try:
|
|
||||||
t["last_checked_at"] = _now_iso()
|
|
||||||
triggers_mod.write(t["name"], t)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for trigger in all_triggers:
|
for trigger in all_triggers:
|
||||||
|
if trigger.get("type") != "watcher":
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if _should_fire(trigger, vars_, now):
|
# Variablen pro Trigger sammeln — wegen prev_near_states-Closure
|
||||||
|
prev = trigger.get("near_states") or {}
|
||||||
|
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
|
||||||
|
|
||||||
|
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
|
||||||
|
fired = _should_fire(trigger, vars_, now)
|
||||||
|
|
||||||
|
# State immer updaten, egal ob gefeuert wurde — sonst greift
|
||||||
|
# entered_near/left_near nicht
|
||||||
|
new_states = vars_.get("_new_near_states") or {}
|
||||||
|
trigger["near_states"] = new_states
|
||||||
|
trigger["last_checked_at"] = _now_iso()
|
||||||
|
try:
|
||||||
|
triggers_mod.write(trigger["name"], trigger)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
if fired:
|
||||||
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
||||||
# darf der naechste Tick nicht blockieren
|
# darf der naechste Tick nicht blockieren
|
||||||
asyncio.create_task(_fire(trigger, agent_factory))
|
asyncio.create_task(_fire(trigger, agent_factory))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
# Timer (one-shot) — separat ohne near-State
|
||||||
|
timer_vars = None
|
||||||
|
for trigger in all_triggers:
|
||||||
|
if trigger.get("type") != "timer":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if timer_vars is None:
|
||||||
|
timer_vars = watcher_mod.collect_variables()
|
||||||
|
if _should_fire(trigger, timer_vars, now):
|
||||||
|
asyncio.create_task(_fire(trigger, agent_factory))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Timer-Check %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von
|
||||||
|
# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen
|
||||||
|
# Lifespan-Pfad geschleust zu werden.
|
||||||
|
_AGENT_FACTORY = None
|
||||||
|
|
||||||
|
|
||||||
|
async def tick_now() -> dict:
|
||||||
|
"""Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick.
|
||||||
|
Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach
|
||||||
|
_persist_location, damit Watcher mit near() den frischen Wert sofort
|
||||||
|
sehen statt bis zu TICK_SEC Sekunden zu warten."""
|
||||||
|
if _AGENT_FACTORY is None:
|
||||||
|
return {"ok": False, "error": "Background-Loop noch nicht gestartet"}
|
||||||
|
try:
|
||||||
|
await _tick(_AGENT_FACTORY)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("tick_now: %s", exc)
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(agent_factory) -> None:
|
async def run_loop(agent_factory) -> None:
|
||||||
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
||||||
|
global _AGENT_FACTORY
|
||||||
|
_AGENT_FACTORY = agent_factory
|
||||||
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+245
-1
@@ -36,6 +36,8 @@ import metrics as metrics_mod
|
|||||||
import triggers as triggers_mod
|
import triggers as triggers_mod
|
||||||
import watcher as watcher_mod
|
import watcher as watcher_mod
|
||||||
import background as background_mod
|
import background as background_mod
|
||||||
|
import oauth as oauth_mod
|
||||||
|
import seed_rules as seed_rules_mod
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
logger = logging.getLogger("aria-brain")
|
logger = logging.getLogger("aria-brain")
|
||||||
@@ -45,7 +47,13 @@ QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen."""
|
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
|
||||||
|
Trigger-Background-Loop anwerfen. Beim Shutdown: Loop stoppen."""
|
||||||
|
try:
|
||||||
|
result = seed_rules_mod.apply(store(), embedder())
|
||||||
|
logger.info("Lifespan: seed_rules angewendet (%s)", result)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
|
||||||
task = asyncio.create_task(background_mod.run_loop(agent))
|
task = asyncio.create_task(background_mod.run_loop(agent))
|
||||||
logger.info("Lifespan: Trigger-Loop gestartet")
|
logger.info("Lifespan: Trigger-Loop gestartet")
|
||||||
try:
|
try:
|
||||||
@@ -657,6 +665,16 @@ def triggers_list(active_only: bool = False):
|
|||||||
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/triggers/check-now")
|
||||||
|
async def triggers_check_now():
|
||||||
|
"""Sofortiger Trigger-Check, statt auf den naechsten Background-Tick
|
||||||
|
zu warten. Wird von der Bridge nach jedem location_update gerufen
|
||||||
|
damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei
|
||||||
|
Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s
|
||||||
|
Drinnen-Zeit, was unter TICK_SEC fallen kann."""
|
||||||
|
return await background_mod.tick_now()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/triggers/conditions")
|
@app.get("/triggers/conditions")
|
||||||
def triggers_conditions():
|
def triggers_conditions():
|
||||||
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
||||||
@@ -739,6 +757,7 @@ class SkillCreate(BaseModel):
|
|||||||
requires: dict = Field(default_factory=dict)
|
requires: dict = Field(default_factory=dict)
|
||||||
pip_packages: list = Field(default_factory=list)
|
pip_packages: list = Field(default_factory=list)
|
||||||
author: str = "stefan"
|
author: str = "stefan"
|
||||||
|
config_schema: list = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SkillRun(BaseModel):
|
class SkillRun(BaseModel):
|
||||||
@@ -751,6 +770,18 @@ class SkillPatch(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
active: bool | None = None
|
active: bool | None = None
|
||||||
args: list | None = None
|
args: list | None = None
|
||||||
|
entry_code: str | None = None
|
||||||
|
readme: str | None = None
|
||||||
|
pip_packages: list | None = None
|
||||||
|
config_schema: list | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillConfigSet(BaseModel):
|
||||||
|
values: dict
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRollback(BaseModel):
|
||||||
|
version_id: str
|
||||||
|
|
||||||
|
|
||||||
@app.get("/skills/list")
|
@app.get("/skills/list")
|
||||||
@@ -767,6 +798,32 @@ def skills_get(name: str):
|
|||||||
return {"manifest": m, "readme": readme}
|
return {"manifest": m, "readme": readme}
|
||||||
|
|
||||||
|
|
||||||
|
class SkillScaffold(BaseModel):
|
||||||
|
name: str
|
||||||
|
template: str # oauth-api | apikey-api | file-process
|
||||||
|
params: dict = Field(default_factory=dict)
|
||||||
|
author: str = "stefan"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/skills/templates")
|
||||||
|
def skills_templates_list():
|
||||||
|
"""Liste der verfuegbaren Templates — fuer UI und Dokumentation."""
|
||||||
|
import skill_templates as st
|
||||||
|
return {"templates": st.list_templates()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/skills/scaffold")
|
||||||
|
def skills_scaffold(body: SkillScaffold):
|
||||||
|
"""Baut einen Skill aus einem Template (oauth-api / apikey-api / file-process)."""
|
||||||
|
try:
|
||||||
|
return skills_mod.scaffold_skill(
|
||||||
|
name=body.name, template=body.template,
|
||||||
|
params=body.params, author=body.author,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/skills/create")
|
@app.post("/skills/create")
|
||||||
def skills_create(body: SkillCreate):
|
def skills_create(body: SkillCreate):
|
||||||
try:
|
try:
|
||||||
@@ -780,6 +837,7 @@ def skills_create(body: SkillCreate):
|
|||||||
requires=body.requires,
|
requires=body.requires,
|
||||||
pip_packages=body.pip_packages,
|
pip_packages=body.pip_packages,
|
||||||
author=body.author,
|
author=body.author,
|
||||||
|
config_schema=body.config_schema,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc))
|
raise HTTPException(400, str(exc))
|
||||||
@@ -816,6 +874,57 @@ def skills_logs(name: str, limit: int = 50):
|
|||||||
return {"logs": skills_mod.list_logs(name, limit=limit)}
|
return {"logs": skills_mod.list_logs(name, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Skill-Configs (P3): statische Werte (API-Keys etc.) je Skill ───
|
||||||
|
|
||||||
|
@app.get("/skills/{name}/config")
|
||||||
|
def skills_config_get(name: str):
|
||||||
|
"""Liefert config_schema + aktuelle Werte (secret-Felder gemaskt mit
|
||||||
|
'***SET***')."""
|
||||||
|
manifest = skills_mod.read_manifest(name)
|
||||||
|
if manifest is None:
|
||||||
|
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
|
||||||
|
return {
|
||||||
|
"schema": manifest.get("config_schema") or [],
|
||||||
|
"values": skills_mod.get_skill_config_masked(name),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/skills/{name}/config")
|
||||||
|
def skills_config_set(name: str, body: SkillConfigSet):
|
||||||
|
"""Setzt Config-Werte (komplett ueberschreibend). Werte greifen ab dem
|
||||||
|
naechsten skill_run. Secret-Felder werden in der Antwort gemaskt."""
|
||||||
|
manifest = skills_mod.read_manifest(name)
|
||||||
|
if manifest is None:
|
||||||
|
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
|
||||||
|
skills_mod.set_skill_config(name, body.values)
|
||||||
|
return {"ok": True, "values": skills_mod.get_skill_config_masked(name)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Skill-Versions (P4): rollback ──────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/skills/{name}/versions")
|
||||||
|
def skills_versions_list(name: str):
|
||||||
|
if skills_mod.read_manifest(name) is None:
|
||||||
|
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
|
||||||
|
return {"versions": skills_mod.list_skill_versions(name)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/skills/{name}/rollback")
|
||||||
|
def skills_rollback(name: str, body: SkillRollback):
|
||||||
|
try:
|
||||||
|
return skills_mod.rollback_skill(name, body.version_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(404, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/skills/{name}/versions/{version_id}")
|
||||||
|
def skills_versions_delete(name: str, version_id: str):
|
||||||
|
try:
|
||||||
|
return skills_mod.delete_skill_version(name, version_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(404, str(exc))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/skills/{name}/export")
|
@app.get("/skills/{name}/export")
|
||||||
def skills_export(name: str):
|
def skills_export(name: str):
|
||||||
try:
|
try:
|
||||||
@@ -839,3 +948,138 @@ async def skills_import(request: Request, overwrite: bool = False):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc))
|
raise HTTPException(400, str(exc))
|
||||||
return {"imported": manifest}
|
return {"imported": manifest}
|
||||||
|
|
||||||
|
|
||||||
|
# ── OAuth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/oauth/services")
|
||||||
|
async def oauth_services_list():
|
||||||
|
"""Liste aller Services mit Status (configured/authenticated/expires)."""
|
||||||
|
return {"services": oauth_mod.list_services()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/oauth/apps")
|
||||||
|
async def oauth_apps_get():
|
||||||
|
"""Liefert die persistierte Provider-Config (client_id sichtbar, client_secret
|
||||||
|
NICHT — wer den Wert braucht muss ihn neu eintragen). Fuer Diagnostic-UI."""
|
||||||
|
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
|
||||||
|
safe = {}
|
||||||
|
for service, entry in apps.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
safe[service] = {
|
||||||
|
"client_id": entry.get("client_id", ""),
|
||||||
|
"has_client_secret": bool(entry.get("client_secret")),
|
||||||
|
"scopes": entry.get("scopes"),
|
||||||
|
"auth_url": entry.get("auth_url"),
|
||||||
|
"token_url": entry.get("token_url"),
|
||||||
|
}
|
||||||
|
return {"apps": safe, "defaults": list(oauth_mod.DEFAULT_PROVIDERS.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAppIn(BaseModel):
|
||||||
|
service: str
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
scopes: Optional[List[str]] = None
|
||||||
|
auth_url: Optional[str] = None
|
||||||
|
token_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oauth/apps")
|
||||||
|
async def oauth_apps_set(body: OAuthAppIn):
|
||||||
|
"""Speichert/aktualisiert eine Provider-Config. Leerer client_secret laesst
|
||||||
|
den bestehenden Wert stehen (damit man die Form ohne Re-Eingabe absenden
|
||||||
|
kann fuer reine scope-Aenderungen)."""
|
||||||
|
service = (body.service or "").strip()
|
||||||
|
if not service or not service.isidentifier() and not all(c.isalnum() or c in "_-" for c in service):
|
||||||
|
raise HTTPException(400, "Ungueltiger service-Name (a-z0-9_- erlaubt)")
|
||||||
|
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
|
||||||
|
entry = apps.get(service) or {}
|
||||||
|
if body.client_id:
|
||||||
|
entry["client_id"] = body.client_id.strip()
|
||||||
|
if body.client_secret:
|
||||||
|
entry["client_secret"] = body.client_secret.strip()
|
||||||
|
if body.scopes is not None:
|
||||||
|
entry["scopes"] = body.scopes
|
||||||
|
if body.auth_url:
|
||||||
|
entry["auth_url"] = body.auth_url.strip()
|
||||||
|
if body.token_url:
|
||||||
|
entry["token_url"] = body.token_url.strip()
|
||||||
|
apps[service] = entry
|
||||||
|
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
|
||||||
|
logger.info("OAuth-App %s gespeichert (client_id=%s, has_secret=%s)",
|
||||||
|
service, entry.get("client_id", ""), bool(entry.get("client_secret")))
|
||||||
|
return {"ok": True, "service": service}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/oauth/apps/{service}")
|
||||||
|
async def oauth_apps_delete(service: str):
|
||||||
|
apps = oauth_mod._load_json(oauth_mod.APPS_FILE)
|
||||||
|
if service in apps:
|
||||||
|
apps.pop(service)
|
||||||
|
oauth_mod._save_json(oauth_mod.APPS_FILE, apps)
|
||||||
|
# Token auch wegwerfen
|
||||||
|
oauth_mod.revoke(service)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oauth/{service}/revoke")
|
||||||
|
async def oauth_revoke_endpoint(service: str):
|
||||||
|
return {"ok": oauth_mod.revoke(service)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/oauth/{service}/token")
|
||||||
|
async def oauth_token_endpoint(service: str):
|
||||||
|
"""Liefert das aktuelle access_token fuer einen Service (mit Auto-Refresh
|
||||||
|
wenn < 60s Restzeit). Nur fuer interne Skill-Aufrufe gedacht — Skills
|
||||||
|
sollen NIEMALS hardcoded client_secrets haben, sondern dieses Endpoint
|
||||||
|
pollen. Antwort: {access_token, expires_at, expires_in_sec}.
|
||||||
|
Bei nicht-autorisiert: 401 mit klarer Message."""
|
||||||
|
try:
|
||||||
|
rec = oauth_mod.get_token(service)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(401, str(exc))
|
||||||
|
expires_at = int(rec.get("expires_at") or 0)
|
||||||
|
import time as _t
|
||||||
|
return {
|
||||||
|
"access_token": rec.get("access_token"),
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"expires_in_sec": max(0, expires_at - int(_t.time())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAuthorizeIn(BaseModel):
|
||||||
|
service: str
|
||||||
|
scopes: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/oauth/authorize")
|
||||||
|
async def oauth_authorize_endpoint(body: OAuthAuthorizeIn):
|
||||||
|
"""Baut eine Authorize-URL fuer einen Service. Diagnostic kann das nutzen
|
||||||
|
um den Auth-Flow manuell anzustossen. ARIA selbst nutzt das Tool
|
||||||
|
`oauth_authorize` (in agent._dispatch_tool gemapped auf die gleiche Logik)."""
|
||||||
|
try:
|
||||||
|
return oauth_mod.build_authorize_url(body.service, scopes=body.scopes)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/oauth-callback")
|
||||||
|
async def oauth_callback_internal(request: Request):
|
||||||
|
"""Wird von aria-bridge gerufen wenn ein RVS oauth_callback ankommt.
|
||||||
|
Macht den state-Match + token-exchange und persistiert."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(400, f"bad json: {exc}")
|
||||||
|
service = (body.get("service") or "").strip()
|
||||||
|
code = (body.get("code") or "").strip()
|
||||||
|
state = (body.get("state") or "").strip()
|
||||||
|
err = body.get("error") or None
|
||||||
|
err_desc = body.get("errorDescription") or None
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(400, "service erforderlich")
|
||||||
|
result = oauth_mod.handle_callback(service, code, state, error=err, error_description=err_desc)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""
|
||||||
|
OAuth-Manager fuer ARIA. Generischer OAuth2 Authorization-Code-Flow fuer
|
||||||
|
Spotify, Google, GitHub, Strava, Microsoft etc.
|
||||||
|
|
||||||
|
Architektur:
|
||||||
|
- Brain haelt einen Pending-Store: state-String → pending Auth-Request
|
||||||
|
(mit timeout). Wenn ein Callback ankommt (via aria-bridge ueber RVS),
|
||||||
|
matched der state und der code wird gegen access_token getauscht.
|
||||||
|
- Token-Storage: /shared/config/oauth_tokens.json (pro Service ein Eintrag
|
||||||
|
mit access_token, refresh_token, expires_at, scope).
|
||||||
|
- Provider-Configs: /shared/config/oauth_apps.json — pro Service
|
||||||
|
{client_id, client_secret, auth_url, token_url, scopes, ...}. Wird
|
||||||
|
typischerweise via Diagnostic-UI gefuellt.
|
||||||
|
- Token-Refresh: automatisch wenn access_token abgelaufen oder < 60s
|
||||||
|
bis Ablauf bei get_token() Aufruf.
|
||||||
|
|
||||||
|
OAuth-Callback-URL: https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service}
|
||||||
|
RVS_PORT_PUBLIC ist nicht zwingend gleich RVS_PORT (port-mapping via TLS-Proxy).
|
||||||
|
ARIA setzt die URL beim Auth-Request automatisch — Stefan muss sie EINMAL pro
|
||||||
|
Service im Provider-Dashboard registrieren.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONFIG_DIR = Path("/shared/config")
|
||||||
|
APPS_FILE = CONFIG_DIR / "oauth_apps.json"
|
||||||
|
TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json"
|
||||||
|
|
||||||
|
# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config
|
||||||
|
# uebersteuert). Aktuell nur Spotify als out-of-the-box Service — fuer alles
|
||||||
|
# andere benutzt ARIA das `oauth_register_provider` Tool (legt Provider on-
|
||||||
|
# demand mit den jeweiligen Endpunkten an). Stefan muss bei jedem Provider
|
||||||
|
# danach nur client_id + client_secret in Diagnostic / App eintragen.
|
||||||
|
DEFAULT_PROVIDERS: dict[str, dict] = {
|
||||||
|
"spotify": {
|
||||||
|
"auth_url": "https://accounts.spotify.com/authorize",
|
||||||
|
"token_url": "https://accounts.spotify.com/api/token",
|
||||||
|
"scopes": ["user-read-playback-state", "user-modify-playback-state",
|
||||||
|
"user-read-currently-playing", "playlist-read-private",
|
||||||
|
"user-library-read"],
|
||||||
|
"client_auth": "basic", # client_id:client_secret als Basic-Auth-Header
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
|
||||||
|
_PENDING: dict[str, dict] = {}
|
||||||
|
PENDING_TTL_SEC = 600 # 10 min — laenger nicht sinnvoll, OAuth-Codes sind eh kurzlebig
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_url(service: str) -> str:
|
||||||
|
"""Baut die Redirect-URL die wir bei der Provider-Auth angeben.
|
||||||
|
Liest RVS_HOST / RVS_PORT_PUBLIC / RVS_TLS aus env."""
|
||||||
|
host = os.environ.get("RVS_HOST", "").strip()
|
||||||
|
if not host:
|
||||||
|
raise RuntimeError("RVS_HOST nicht gesetzt — OAuth-Callbacks nicht moeglich")
|
||||||
|
port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
|
||||||
|
tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
|
||||||
|
scheme = "https" if tls else "http"
|
||||||
|
# Default-Ports 443/80 nicht in URL anhaengen
|
||||||
|
if (tls and port == "443") or (not tls and port == "80"):
|
||||||
|
return f"{scheme}://{host}/oauth/callback/{service}"
|
||||||
|
return f"{scheme}://{host}:{port}/oauth/callback/{service}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("OAuth-Datei %s lesen fehlgeschlagen: %s", path, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_json(path: Path, data: dict) -> None:
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
# 600 — enthaelt Secrets
|
||||||
|
try: os.chmod(path, 0o600)
|
||||||
|
except OSError: pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("OAuth-Datei %s speichern fehlgeschlagen: %s", path, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_config(service: str) -> dict:
|
||||||
|
"""Mergt Default-Provider-Config mit User-Override aus oauth_apps.json."""
|
||||||
|
defaults = DEFAULT_PROVIDERS.get(service, {}).copy()
|
||||||
|
apps = _load_json(APPS_FILE)
|
||||||
|
user = (apps.get(service) or {}).copy()
|
||||||
|
# Tiefes Merge nicht noetig — die kollidierenden Felder sind alle scalar/list.
|
||||||
|
merged = {**defaults, **user}
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_credentials(service: str) -> tuple[str, str]:
|
||||||
|
"""Liest client_id + client_secret aus oauth_apps.json. Wirft wenn nicht
|
||||||
|
konfiguriert — der OAuth-Flow kann ohne nicht starten."""
|
||||||
|
apps = _load_json(APPS_FILE)
|
||||||
|
entry = apps.get(service) or {}
|
||||||
|
cid = (entry.get("client_id") or "").strip()
|
||||||
|
sec = (entry.get("client_secret") or "").strip()
|
||||||
|
if not cid or not sec:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"OAuth-App '{service}' nicht konfiguriert. Bitte in Diagnostic > "
|
||||||
|
f"OAuth-Apps client_id + client_secret eintragen."
|
||||||
|
)
|
||||||
|
return cid, sec
|
||||||
|
|
||||||
|
|
||||||
|
def register_provider(service: str, auth_url: str, token_url: str,
|
||||||
|
scopes: Optional[list[str]] = None,
|
||||||
|
client_auth: str = "body",
|
||||||
|
extra_auth_params: Optional[dict] = None,
|
||||||
|
accept_header: Optional[str] = None) -> dict:
|
||||||
|
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
|
||||||
|
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
|
||||||
|
vom Brain-Tool `oauth_register_provider` gerufen.
|
||||||
|
|
||||||
|
Wenn der Service schon existiert: URLs/Scopes werden ueberschrieben,
|
||||||
|
aber vorhandene client_id/client_secret bleiben unberuehrt.
|
||||||
|
"""
|
||||||
|
svc = (service or "").strip()
|
||||||
|
if not svc or not all(c.isalnum() or c in "_-" for c in svc) or len(svc) > 60:
|
||||||
|
raise ValueError(f"Ungueltiger service-Name: {service!r}")
|
||||||
|
if not auth_url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError(f"auth_url muss http(s):// sein: {auth_url!r}")
|
||||||
|
if not token_url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError(f"token_url muss http(s):// sein: {token_url!r}")
|
||||||
|
if client_auth not in ("body", "basic"):
|
||||||
|
raise ValueError(f"client_auth muss 'body' oder 'basic' sein: {client_auth!r}")
|
||||||
|
|
||||||
|
apps = _load_json(APPS_FILE)
|
||||||
|
entry = apps.get(svc) or {}
|
||||||
|
entry["auth_url"] = auth_url.strip()
|
||||||
|
entry["token_url"] = token_url.strip()
|
||||||
|
if scopes is not None:
|
||||||
|
entry["scopes"] = list(scopes)
|
||||||
|
entry["client_auth"] = client_auth
|
||||||
|
if extra_auth_params is not None:
|
||||||
|
entry["extra_auth_params"] = extra_auth_params
|
||||||
|
if accept_header is not None:
|
||||||
|
entry["accept_header"] = accept_header
|
||||||
|
apps[svc] = entry
|
||||||
|
_save_json(APPS_FILE, apps)
|
||||||
|
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
|
||||||
|
svc, auth_url, token_url, len(entry.get("scopes") or []))
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_pending() -> None:
|
||||||
|
"""Entfernt abgelaufene Pending-Auths."""
|
||||||
|
now = time.time()
|
||||||
|
for state, info in list(_PENDING.items()):
|
||||||
|
if now - info.get("created_at", 0) > PENDING_TTL_SEC:
|
||||||
|
_PENDING.pop(state, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authorize ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_authorize_url(service: str, scopes: Optional[list[str]] = None,
|
||||||
|
extra_params: Optional[dict] = None) -> dict:
|
||||||
|
"""Baut die Authorize-URL fuer einen Provider. Speichert den state
|
||||||
|
im Pending-Store. Returns {url, state, redirect_uri, service}.
|
||||||
|
|
||||||
|
Wird vom Brain-Tool oauth_authorize gerufen. ARIA gibt die url an Stefan,
|
||||||
|
der oeffnet sie im Browser, autorisiert, Provider redirected zur
|
||||||
|
redirect_uri (= RVS), RVS broadcasted, bridge forwarded, brain matched
|
||||||
|
state → exchange.
|
||||||
|
"""
|
||||||
|
_cleanup_pending()
|
||||||
|
cfg = _provider_config(service)
|
||||||
|
if not cfg.get("auth_url") or not cfg.get("token_url"):
|
||||||
|
raise RuntimeError(f"Provider '{service}' hat keine auth_url/token_url. "
|
||||||
|
f"In oauth_apps.json eintragen oder einen der "
|
||||||
|
f"vordefinierten Services nutzen ({', '.join(DEFAULT_PROVIDERS)}).")
|
||||||
|
cid, _ = _provider_credentials(service)
|
||||||
|
redirect_uri = _callback_url(service)
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
use_scopes = scopes if scopes else cfg.get("scopes") or []
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": cid,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
if use_scopes:
|
||||||
|
params["scope"] = " ".join(use_scopes)
|
||||||
|
params.update(cfg.get("extra_auth_params") or {})
|
||||||
|
if extra_params:
|
||||||
|
params.update(extra_params)
|
||||||
|
|
||||||
|
url = cfg["auth_url"] + "?" + urllib.parse.urlencode(params)
|
||||||
|
|
||||||
|
_PENDING[state] = {
|
||||||
|
"service": service,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scopes": use_scopes,
|
||||||
|
"created_at": time.time(),
|
||||||
|
}
|
||||||
|
logger.info("[oauth] Authorize-URL fuer %s gebaut: state=%s redirect=%s",
|
||||||
|
service, state[:8] + "...", redirect_uri)
|
||||||
|
return {"url": url, "state": state, "redirect_uri": redirect_uri, "service": service}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token-Exchange ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _token_request(token_url: str, body_params: dict, cfg: dict,
|
||||||
|
client_id: str, client_secret: str) -> dict:
|
||||||
|
"""POST an provider /token endpoint. Returns parsed JSON oder wirft."""
|
||||||
|
data = urllib.parse.urlencode(body_params).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
if cfg.get("accept_header"):
|
||||||
|
headers["Accept"] = cfg["accept_header"]
|
||||||
|
# Client-Auth: 'basic' (Header) oder 'body' (im Form-Body)
|
||||||
|
if cfg.get("client_auth") == "basic":
|
||||||
|
auth_str = f"{client_id}:{client_secret}"
|
||||||
|
b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
|
||||||
|
headers["Authorization"] = f"Basic {b64}"
|
||||||
|
else:
|
||||||
|
# bereits im body_params drin (siehe Caller)
|
||||||
|
pass
|
||||||
|
req = urllib.request.Request(token_url, data=data, method="POST", headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", "ignore")
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# GitHub default ist form-urlencoded — accept_header sollte
|
||||||
|
# JSON anfordern, aber falls's doch mal kommt:
|
||||||
|
parsed = urllib.parse.parse_qs(raw)
|
||||||
|
return {k: v[0] if isinstance(v, list) and v else v for k, v in parsed.items()}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "ignore")[:500]
|
||||||
|
raise RuntimeError(f"Token-Request HTTP {e.code}: {body}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def handle_callback(service: str, code: str, state: str,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
error_description: Optional[str] = None) -> dict:
|
||||||
|
"""Verarbeitet einen OAuth-Callback. Validiert state, tauscht code gegen
|
||||||
|
Token, speichert. Returns {ok, service, message, ...}.
|
||||||
|
|
||||||
|
Wird von /internal/oauth-callback (HTTP, von aria-bridge) gerufen.
|
||||||
|
"""
|
||||||
|
_cleanup_pending()
|
||||||
|
|
||||||
|
if error:
|
||||||
|
# Provider hat User-Abbruch oder Fehler gemeldet
|
||||||
|
_PENDING.pop(state, None) if state else None
|
||||||
|
logger.warning("[oauth] Provider-Error %s/%s: %s — %s",
|
||||||
|
service, state[:8] + "..." if state else "?", error, error_description)
|
||||||
|
return {"ok": False, "service": service, "error": error,
|
||||||
|
"errorDescription": error_description}
|
||||||
|
|
||||||
|
pending = _PENDING.pop(state, None)
|
||||||
|
if not pending:
|
||||||
|
logger.warning("[oauth] Unknown state %s fuer %s — abgelaufen oder CSRF?", state[:8] + "...", service)
|
||||||
|
return {"ok": False, "service": service,
|
||||||
|
"error": "invalid_state",
|
||||||
|
"errorDescription": "Unbekannter oder abgelaufener state (Auth-Anfrage muss erst per oauth_authorize neu gestartet werden)."}
|
||||||
|
if pending.get("service") != service:
|
||||||
|
logger.warning("[oauth] state-Service-Mismatch: pending=%s vs callback=%s",
|
||||||
|
pending.get("service"), service)
|
||||||
|
return {"ok": False, "service": service,
|
||||||
|
"error": "service_mismatch",
|
||||||
|
"errorDescription": "state gehoert zu einem anderen Service."}
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return {"ok": False, "service": service, "error": "no_code"}
|
||||||
|
|
||||||
|
cfg = _provider_config(service)
|
||||||
|
try:
|
||||||
|
client_id, client_secret = _provider_credentials(service)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {"ok": False, "service": service, "error": "no_credentials",
|
||||||
|
"errorDescription": str(exc)}
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": pending["redirect_uri"],
|
||||||
|
}
|
||||||
|
if cfg.get("client_auth") != "basic":
|
||||||
|
body["client_id"] = client_id
|
||||||
|
body["client_secret"] = client_secret
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("[oauth] Token-Exchange fehlgeschlagen fuer %s", service)
|
||||||
|
return {"ok": False, "service": service, "error": "exchange_failed",
|
||||||
|
"errorDescription": str(exc)[:200]}
|
||||||
|
|
||||||
|
access = token_data.get("access_token")
|
||||||
|
if not access:
|
||||||
|
return {"ok": False, "service": service, "error": "no_access_token",
|
||||||
|
"errorDescription": str(token_data)[:200]}
|
||||||
|
|
||||||
|
expires_in = int(token_data.get("expires_in") or 3600)
|
||||||
|
refresh = token_data.get("refresh_token") or ""
|
||||||
|
scope = token_data.get("scope") or " ".join(pending.get("scopes") or [])
|
||||||
|
token_type = token_data.get("token_type") or "Bearer"
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"service": service,
|
||||||
|
"access_token": access,
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"token_type": token_type,
|
||||||
|
"scope": scope,
|
||||||
|
"expires_at": int(time.time()) + expires_in,
|
||||||
|
"obtained_at": int(time.time()),
|
||||||
|
}
|
||||||
|
_persist_token(service, record)
|
||||||
|
logger.info("[oauth] %s authentifiziert — expires in %ds, refresh=%s",
|
||||||
|
service, expires_in, "ja" if refresh else "nein")
|
||||||
|
return {"ok": True, "service": service, "expiresIn": expires_in,
|
||||||
|
"hasRefresh": bool(refresh), "scope": scope}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token-Storage / Refresh / Revoke ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_token(service: str, record: dict) -> None:
|
||||||
|
tokens = _load_json(TOKENS_FILE)
|
||||||
|
tokens[service] = record
|
||||||
|
_save_json(TOKENS_FILE, tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_token(service: str) -> Optional[dict]:
|
||||||
|
return _load_json(TOKENS_FILE).get(service)
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(service: str, refresh_threshold_sec: int = 60) -> dict:
|
||||||
|
"""Holt das aktuelle access_token fuer einen Service. Refresht automatisch
|
||||||
|
wenn weniger als refresh_threshold_sec Restzeit. Returns das ganze
|
||||||
|
record-dict — Caller nimmt sich access_token raus.
|
||||||
|
|
||||||
|
Wirft wenn nicht authentifiziert oder Refresh fehlschlaegt — Tool-Aufrufer
|
||||||
|
soll dann oauth_authorize anbieten."""
|
||||||
|
record = _load_token(service)
|
||||||
|
if not record:
|
||||||
|
raise RuntimeError(f"Kein Token fuer '{service}' gespeichert. Erst per "
|
||||||
|
f"oauth_authorize authentifizieren.")
|
||||||
|
exp = int(record.get("expires_at") or 0)
|
||||||
|
remaining = exp - int(time.time())
|
||||||
|
if remaining > refresh_threshold_sec:
|
||||||
|
return record
|
||||||
|
# Refresh noetig
|
||||||
|
refresh_tok = (record.get("refresh_token") or "").strip()
|
||||||
|
if not refresh_tok:
|
||||||
|
raise RuntimeError(f"Token fuer '{service}' abgelaufen und kein refresh_token "
|
||||||
|
f"vorhanden — bitte neu autorisieren mit oauth_authorize.")
|
||||||
|
cfg = _provider_config(service)
|
||||||
|
client_id, client_secret = _provider_credentials(service)
|
||||||
|
body = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_tok,
|
||||||
|
}
|
||||||
|
if cfg.get("client_auth") != "basic":
|
||||||
|
body["client_id"] = client_id
|
||||||
|
body["client_secret"] = client_secret
|
||||||
|
try:
|
||||||
|
new_data = _token_request(cfg["token_url"], body, cfg, client_id, client_secret)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Token-Refresh fuer '{service}' fehlgeschlagen: {exc}") from exc
|
||||||
|
|
||||||
|
new_access = new_data.get("access_token")
|
||||||
|
if not new_access:
|
||||||
|
raise RuntimeError(f"Refresh-Antwort ohne access_token: {new_data}")
|
||||||
|
expires_in = int(new_data.get("expires_in") or 3600)
|
||||||
|
# refresh_token kann (manche Provider) bei jedem Refresh rotieren
|
||||||
|
new_refresh = (new_data.get("refresh_token") or refresh_tok).strip()
|
||||||
|
record.update({
|
||||||
|
"access_token": new_access,
|
||||||
|
"refresh_token": new_refresh,
|
||||||
|
"expires_at": int(time.time()) + expires_in,
|
||||||
|
"obtained_at": int(time.time()),
|
||||||
|
})
|
||||||
|
if new_data.get("scope"):
|
||||||
|
record["scope"] = new_data["scope"]
|
||||||
|
_persist_token(service, record)
|
||||||
|
logger.info("[oauth] %s Token refreshed — neue Restzeit %ds", service, expires_in)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(service: str) -> bool:
|
||||||
|
"""Entfernt das Token aus dem Storage (Best-Effort, kein Provider-Revoke-Call)."""
|
||||||
|
tokens = _load_json(TOKENS_FILE)
|
||||||
|
if service not in tokens:
|
||||||
|
return False
|
||||||
|
tokens.pop(service, None)
|
||||||
|
_save_json(TOKENS_FILE, tokens)
|
||||||
|
logger.info("[oauth] %s Token geloescht (lokal).", service)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_services() -> list[dict]:
|
||||||
|
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
|
||||||
|
+ Ablaufzeit. Wird von Diagnostic genutzt."""
|
||||||
|
apps = _load_json(APPS_FILE)
|
||||||
|
tokens = _load_json(TOKENS_FILE)
|
||||||
|
out = []
|
||||||
|
services = set(apps.keys()) | set(tokens.keys()) | set(DEFAULT_PROVIDERS.keys())
|
||||||
|
now = int(time.time())
|
||||||
|
for s in sorted(services):
|
||||||
|
app = apps.get(s) or {}
|
||||||
|
tok = tokens.get(s) or {}
|
||||||
|
configured = bool(app.get("client_id") and app.get("client_secret"))
|
||||||
|
out.append({
|
||||||
|
"service": s,
|
||||||
|
"configured": configured,
|
||||||
|
"authenticated": bool(tok.get("access_token")),
|
||||||
|
"expiresAt": tok.get("expires_at"),
|
||||||
|
"expiresInSec": (tok.get("expires_at", 0) - now) if tok.get("expires_at") else None,
|
||||||
|
"hasRefresh": bool(tok.get("refresh_token")),
|
||||||
|
"scope": tok.get("scope", ""),
|
||||||
|
"isDefault": s in DEFAULT_PROVIDERS,
|
||||||
|
})
|
||||||
|
return out
|
||||||
+117
-11
@@ -164,15 +164,17 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
|
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
|
||||||
"Stefan fragen ob es ins Brain-Dockerfile soll.")
|
"Stefan fragen ob es ins Brain-Dockerfile soll.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Harte Regel — IMMER Skill anlegen wenn:** die Loesung erfordert eine "
|
lines.append("**Goldene Regel: NIE ungefragt Skills anlegen.** Selbst wenn die Aufgabe "
|
||||||
"pip-Library. Begruendung: Brain-Container hat keinen persistenten State "
|
"eine pip-Library braucht — erst die Aufgabe loesen (mit Bash, `pip install` "
|
||||||
"ausser /data/skills/. Ohne Skill wuerde der Install bei jedem "
|
"im Brain ist ok, oder Workaround), und nur wenn Stefan EXPLIZIT sagt "
|
||||||
"Container-Restart wiederholt.")
|
"'mach daraus einen Skill' / 'leg den als Skill an' / 'dafuer einen Skill' "
|
||||||
|
"rufst du `skill_create` auf. Begruendung: Skill-Setup (venv + pip install) "
|
||||||
|
"blockt das Brain bis zu 12 Minuten. Ein unaufgefordert angelegter Skill "
|
||||||
|
"macht ARIA stumm und nervt Stefan jedes Mal.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("**Sonst — Skill nur wenn alle vier zutreffen:**")
|
lines.append("**Wenn Stefan einen Skill explizit moechte, pruef:**")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt. "
|
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt.")
|
||||||
"Einmal-Faelle (\"wie spaet ist es jetzt\") kein Skill.")
|
|
||||||
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
|
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
|
||||||
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
|
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
|
||||||
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
|
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
|
||||||
@@ -180,9 +182,8 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
|
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
|
||||||
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
|
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Wenn nichts installiert werden muss UND nicht alle vier zutreffen: einfach "
|
lines.append("Wenn auch nur EINE der vier nicht zutrifft: hoeflich nachfragen ob er "
|
||||||
"die Aufgabe loesen ohne Skill anzulegen. Stefan kann jederzeit sagen "
|
"wirklich einen permanenten Skill will oder die Aufgabe einmalig reicht.")
|
||||||
"'bau daraus einen Skill'.")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -239,6 +240,94 @@ def build_triggers_section(
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_oauth_section(oauth_services: list[dict] | None,
|
||||||
|
callback_host: str = "",
|
||||||
|
callback_port: str = "443",
|
||||||
|
callback_tls: bool = True) -> str:
|
||||||
|
"""Block fuer den System-Prompt: zeigt ARIA welche externen Services
|
||||||
|
via OAuth verfuegbar sind, welche schon authentifiziert sind, und welche
|
||||||
|
Callback-URL beim Provider eingetragen werden muss."""
|
||||||
|
scheme = "https" if callback_tls else "http"
|
||||||
|
if callback_host:
|
||||||
|
if (callback_tls and callback_port == "443") or (not callback_tls and callback_port == "80"):
|
||||||
|
base = f"{scheme}://{callback_host}/oauth/callback/<SERVICE>"
|
||||||
|
else:
|
||||||
|
base = f"{scheme}://{callback_host}:{callback_port}/oauth/callback/<SERVICE>"
|
||||||
|
else:
|
||||||
|
base = "<nicht konfiguriert — RVS_HOST in brain env fehlt>"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"## OAuth externe Services",
|
||||||
|
"",
|
||||||
|
"Du kannst Spotify, Google, GitHub, Strava, Microsoft (und custom-konfigurierte) "
|
||||||
|
"Services via OAuth2 ansprechen. Workflow ist IMMER:",
|
||||||
|
"1. `oauth_get_token(service)` versuchen — Token vorhanden? → benutzen.",
|
||||||
|
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
|
||||||
|
"",
|
||||||
|
f"**Callback-URL (fest, NICHT raten):** `{base}`",
|
||||||
|
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
|
||||||
|
"Redirect-URI eintragen. Sie ist hardcoded an die RVS-Infrastruktur gebunden "
|
||||||
|
"und aendert sich nicht — auch nicht wenn Du als Brain neu aufgesetzt wirst.",
|
||||||
|
"",
|
||||||
|
"**NICHT** versuchen client_id / client_secret selbst zu generieren oder zu "
|
||||||
|
"raten. Wenn nicht eingetragen → Stefan sagen er soll es in Diagnostic > "
|
||||||
|
"OAuth-Apps machen.",
|
||||||
|
]
|
||||||
|
if oauth_services:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("**Aktuelle Service-Status:**")
|
||||||
|
for s in oauth_services:
|
||||||
|
name = s.get("service", "?")
|
||||||
|
configured = s.get("configured", False)
|
||||||
|
auth = s.get("authenticated", False)
|
||||||
|
remain = s.get("expiresInSec")
|
||||||
|
parts = []
|
||||||
|
if not configured:
|
||||||
|
parts.append("Credentials fehlen")
|
||||||
|
elif not auth:
|
||||||
|
parts.append("nicht authentifiziert")
|
||||||
|
else:
|
||||||
|
if remain is None:
|
||||||
|
parts.append("authentifiziert")
|
||||||
|
elif remain > 0:
|
||||||
|
parts.append(f"authentifiziert, Token gueltig noch {remain}s")
|
||||||
|
else:
|
||||||
|
parts.append("Token abgelaufen (wird automatisch refresht)")
|
||||||
|
lines.append(f"- `{name}`: {' / '.join(parts)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_flux_section(flux_config: dict) -> str:
|
||||||
|
"""Block fuer den System-Prompt: aktuelle Diagnostic-Settings fuer
|
||||||
|
Bildgenerierung (Default-Modell + User-konfigurierbare Keywords).
|
||||||
|
|
||||||
|
flux_config kommt aus /shared/config/voice_config.json:
|
||||||
|
fluxDefaultModel: "dev" | "schnell" (Default "dev")
|
||||||
|
fluxKeywordRaw: z.B. "flux" (Pipe-Modus, kein Rewriting)
|
||||||
|
fluxKeywordSwitch:z.B. "fix" (anderes Modell als Default)
|
||||||
|
"""
|
||||||
|
default_model = (flux_config or {}).get("fluxDefaultModel", "dev")
|
||||||
|
kw_raw = (flux_config or {}).get("fluxKeywordRaw", "flux")
|
||||||
|
kw_switch = (flux_config or {}).get("fluxKeywordSwitch", "fix")
|
||||||
|
other_model = "schnell" if default_model == "dev" else "dev"
|
||||||
|
lines = [
|
||||||
|
"## FLUX Bildgenerierung",
|
||||||
|
f"- Default-Modell: `{default_model}` (alternativ: `{other_model}`).",
|
||||||
|
f"- Raw-Keyword: `{kw_raw}` — wenn Stefans Nachricht damit beginnt "
|
||||||
|
f"oder das Wort als ersten echten Wortteil enthaelt, ruf "
|
||||||
|
f"`flux_generate(..., raw=true)` und leite seinen Text 1:1 als prompt "
|
||||||
|
f"durch. KEIN Uebersetzen, KEIN Beautify, KEINE Stil-Adds.",
|
||||||
|
f"- Switch-Keyword: `{kw_switch}` — taucht's in der Nachricht auf, "
|
||||||
|
f"setze `model=\"{other_model}\"` (das ANDERE Modell als das Default).",
|
||||||
|
"- Natuerliche Sprache funktioniert auch: 'mal eben fix' / 'schnell' → schnell, "
|
||||||
|
"'in hoher Qualitaet' / 'detailliert' → dev.",
|
||||||
|
"- Whisper-Erkennung des Raw-Keywords ist nicht perfekt — wenn Stefans "
|
||||||
|
"Sprachnachricht z.B. mit 'fluks', 'flocks', 'fluxx' anfaengt, behandle "
|
||||||
|
"das auch als Raw-Keyword.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def build_system_prompt(
|
def build_system_prompt(
|
||||||
pinned: List[MemoryPoint],
|
pinned: List[MemoryPoint],
|
||||||
cold: List[MemoryPoint] | None = None,
|
cold: List[MemoryPoint] | None = None,
|
||||||
@@ -246,8 +335,13 @@ def build_system_prompt(
|
|||||||
triggers: List[dict] | None = None,
|
triggers: List[dict] | None = None,
|
||||||
condition_vars: List[dict] | None = None,
|
condition_vars: List[dict] | None = None,
|
||||||
condition_funcs: List[dict] | None = None,
|
condition_funcs: List[dict] | None = None,
|
||||||
|
flux_config: dict | None = None,
|
||||||
|
oauth_services: list[dict] | None = None,
|
||||||
|
oauth_callback_host: str = "",
|
||||||
|
oauth_callback_port: str = "443",
|
||||||
|
oauth_callback_tls: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
|
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
|
||||||
parts = [build_hot_memory_section(pinned), "", build_time_section()]
|
parts = [build_hot_memory_section(pinned), "", build_time_section()]
|
||||||
if skills:
|
if skills:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
@@ -255,6 +349,18 @@ def build_system_prompt(
|
|||||||
if condition_vars:
|
if condition_vars:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
|
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
|
||||||
|
if flux_config is not None:
|
||||||
|
parts.append("")
|
||||||
|
parts.append(build_flux_section(flux_config))
|
||||||
|
# OAuth-Block bauen wir nur wenn RVS_HOST konfiguriert ist (sonst hat
|
||||||
|
# die Callback-URL keinen Sinn). Sonst lassen wir den Block weg statt
|
||||||
|
# ARIA eine "<nicht konfiguriert>"-URL zu zeigen.
|
||||||
|
if oauth_callback_host:
|
||||||
|
parts.append("")
|
||||||
|
parts.append(build_oauth_section(oauth_services,
|
||||||
|
callback_host=oauth_callback_host,
|
||||||
|
callback_port=oauth_callback_port,
|
||||||
|
callback_tls=oauth_callback_tls))
|
||||||
if cold:
|
if cold:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(build_cold_memory_section(cold))
|
parts.append(build_cold_memory_section(cold))
|
||||||
|
|||||||
@@ -25,7 +25,17 @@ logger = logging.getLogger(__name__)
|
|||||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||||
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
||||||
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
||||||
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "300"))
|
# Read-Timeout: wie lange wir auf die HTTP-Antwort vom Proxy warten.
|
||||||
|
# Proxy ist non-streaming → erstes Byte kommt erst NACH subprocess close.
|
||||||
|
# Agent-Loops (Pentests etc.) koennen >1h dauern → muss hoch sein.
|
||||||
|
# Default 24h, kann via PROXY_TIMEOUT_SEC env ueberschrieben werden.
|
||||||
|
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "86400"))
|
||||||
|
# Connect/Write/Pool: klein damit toter Proxy schnell erkannt wird.
|
||||||
|
# Wenn der Proxy-Container nicht antwortet beim TCP-Connect oder waehrend
|
||||||
|
# wir den Request-Body schreiben, ist er kaputt — kein Grund 24h zu warten.
|
||||||
|
PROXY_CONNECT_TIMEOUT_SEC = float(os.environ.get("PROXY_CONNECT_TIMEOUT_SEC", "10"))
|
||||||
|
PROXY_WRITE_TIMEOUT_SEC = float(os.environ.get("PROXY_WRITE_TIMEOUT_SEC", "30"))
|
||||||
|
PROXY_POOL_TIMEOUT_SEC = float(os.environ.get("PROXY_POOL_TIMEOUT_SEC", "10"))
|
||||||
|
|
||||||
|
|
||||||
def _read_model_from_runtime() -> str:
|
def _read_model_from_runtime() -> str:
|
||||||
@@ -62,8 +72,15 @@ class ProxyClient:
|
|||||||
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
|
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.model = model
|
self.model = model
|
||||||
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call
|
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
|
||||||
self._client = httpx.Client(timeout=PROXY_TIMEOUT_SEC)
|
# Timeouts split nach Phase: connect/write/pool klein (toter Proxy → schnell
|
||||||
|
# ReadTimeout), read gross (ARIA darf ewig rechnen).
|
||||||
|
self._client = httpx.Client(timeout=httpx.Timeout(
|
||||||
|
connect=PROXY_CONNECT_TIMEOUT_SEC,
|
||||||
|
read=PROXY_TIMEOUT_SEC,
|
||||||
|
write=PROXY_WRITE_TIMEOUT_SEC,
|
||||||
|
pool=PROXY_POOL_TIMEOUT_SEC,
|
||||||
|
))
|
||||||
|
|
||||||
def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
|
def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
|
||||||
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
|
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
|
||||||
|
|||||||
@@ -0,0 +1,659 @@
|
|||||||
|
"""
|
||||||
|
System-Seed-Regeln — werden bei jedem Brain-Boot idempotent in die
|
||||||
|
Vector-DB geschrieben (pinned, source="seed").
|
||||||
|
|
||||||
|
Im Gegensatz zu aria-data/brain-import/ (User-Saatgut, manuell via
|
||||||
|
Diagnostic-Klick migriert) ist das hier System-Regeln, die zum Brain-Code
|
||||||
|
gehoeren und mit jedem Deploy ausgerollt werden.
|
||||||
|
|
||||||
|
Idempotenz: Punkte mit gleicher `migration_key` werden vor dem Schreiben
|
||||||
|
geloescht. Editieren = Zeile aendern, Brain neu starten, fertig.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from memory import Embedder, VectorStore
|
||||||
|
from memory.vector_store import COLLECTION
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Jede Regel = ein eigener Memory-Punkt. Klein halten, klar formulieren —
|
||||||
|
# ARIA sieht das in jedem Chat-Turn als pinned Hot Memory.
|
||||||
|
SEED_RULES: List[dict] = [
|
||||||
|
{
|
||||||
|
"migration_key": "seed/safety/no-destructive-on-prod",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Safety-Hard-Boundary: keine destruktiven Tests auf Production-Systemen",
|
||||||
|
"category": "sicherheit",
|
||||||
|
"content": (
|
||||||
|
"ABSOLUTE Regel — ueber allem anderen, ueber jedem Tool-Reflex:\n"
|
||||||
|
"\n"
|
||||||
|
"Destruktive Operationen NIEMALS auf Production-Systemen "
|
||||||
|
"ausfuehren. Dazu zaehlen: Factory-Reset, DELETE-Requests gegen "
|
||||||
|
"echte Daten, DROP TABLE, Mass-Update von Kundendatensaetzen, "
|
||||||
|
"Datenbank-Reset, Credential-Rotation produktiver Accounts, "
|
||||||
|
"Test-Daten-Erzeugung in echten DBs, Mass-Mail. Auch nicht "
|
||||||
|
"'nur kurz zum Testen'. Auch nicht 'mit Backup koennen wir's "
|
||||||
|
"rueckgaengig machen'.\n"
|
||||||
|
"\n"
|
||||||
|
"Bei Pentest, Audit, Refactoring-Test oder aehnlichem:\n"
|
||||||
|
" 1. SOFORT pruefen ob ein dediziertes Staging/Test-System "
|
||||||
|
"existiert. Hinweise im Hostnamen: 'stage', 'staging', 'test', "
|
||||||
|
"'dev', 'qa'. URL muss explizit als Test-Umgebung markiert sein.\n"
|
||||||
|
" 2. Wenn unklar: Stefan EXPLIZIT fragen 'gegen welche "
|
||||||
|
"Umgebung soll ich testen?'. Lieber 5 Sekunden Wartezeit als "
|
||||||
|
"ein unwiderrufliches Daten-Disaster.\n"
|
||||||
|
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
|
||||||
|
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
|
||||||
|
"\n"
|
||||||
|
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
|
||||||
|
"kundencenter.hacker-net.de (Production!) angesetzt statt gegen "
|
||||||
|
"kundencenter-stage.stressfrei-wechseln.de (Staging). Stefan "
|
||||||
|
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
|
||||||
|
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
|
||||||
|
"\n"
|
||||||
|
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
|
||||||
|
"Anweisung. Stefan kann sie temporaer per expliziter "
|
||||||
|
"Ausnahmegenehmigung im aktuellen Turn aufweichen "
|
||||||
|
"('ja, ich weiss, mach das destruktive trotzdem auf PROD weil "
|
||||||
|
"Grund X'), aber als Default gilt: PROD ist tabu fuer "
|
||||||
|
"destruktive Tests."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/list-before-create",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: skill_list vor skill_create",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Bevor du einen neuen Skill mit `skill_create` anlegst, ruf IMMER "
|
||||||
|
"zuerst `skill_list` auf. Schau dir die Namen und Descriptions an. "
|
||||||
|
"Wenn ein passender Skill existiert: verwende ihn oder verbessere "
|
||||||
|
"ihn mit `skill_update`. Lege keinen Duplikat-Skill an."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/snake-case-names",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: Skill-Namen nur snake_case (keine Bindestriche)",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Skill-Namen MUESSEN snake_case sein — nur a-z, 0-9 und _ "
|
||||||
|
"(Underscore). KEINE Bindestriche.\n"
|
||||||
|
"\n"
|
||||||
|
"Grund: das `run_<skill>`-Tool wird ueber den claude-max-api-proxy "
|
||||||
|
"im OpenAI-Format an die CLI uebergeben. Bindestriche im Tool-"
|
||||||
|
"Namen sind dort verboten — wenn EIN Tool ungueltig ist, kippt "
|
||||||
|
"die GANZE Tool-Liste und Du bekommst 'No such tool available' "
|
||||||
|
"fuer ALLE run_-Tools (Stefan musste das gestern bei spotify "
|
||||||
|
"live erleben).\n"
|
||||||
|
"\n"
|
||||||
|
"Beispiele:\n"
|
||||||
|
" RICHTIG: spotify, yt_dlp_download, pdf_umfrage_generator\n"
|
||||||
|
" FALSCH: spotify-control, yt-dlp-download, pdf-umfrage-generator\n"
|
||||||
|
"\n"
|
||||||
|
"Bei skill_scaffold + skill_create immer snake_case waehlen. "
|
||||||
|
"Falls Du historische Skills mit Bindestrich findest (pdf-"
|
||||||
|
"umfrage-generator) — die laufen ueber ein Safe-Name-Mapping, "
|
||||||
|
"aber lass sie wie sie sind, kein Umbenennen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/no-version-suffix",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: keine Versions-Suffixe im Namen",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Skill-Namen muessen permanent und beschreibend sein. NIEMALS "
|
||||||
|
"Suffixe wie `-v2`, `_v3`, `-new`, `-fixed`, `-aria`, `-ctl` "
|
||||||
|
"anhaengen, um eine neue Variante zu bauen. Wenn ein Skill kaputt "
|
||||||
|
"ist oder verbessert werden soll: `skill_update`. Versionsverwaltung "
|
||||||
|
"macht das System intern (Rollback ueber `skill_rollback`)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/update-not-recreate",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: kaputten Skill reparieren, nicht neu bauen",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn ein vorhandener Skill nicht wie erwartet funktioniert, lies "
|
||||||
|
"zuerst Code + Logs (`skill_get`, `skill_logs`). Repariere ihn dann "
|
||||||
|
"mit `skill_update` (entry_code, readme oder pip_packages patchen). "
|
||||||
|
"Baue NIEMALS einen zweiten Skill mit aehnlichem Namen — das gibt "
|
||||||
|
"Skill-Friedhof und Stefan muss aufraeumen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: keine hardcoded Credentials",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
|
||||||
|
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
|
||||||
|
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
|
||||||
|
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
|
||||||
|
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
|
||||||
|
"manuell neu einloggen, das nervt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/config-schema-for-settings",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: konfigurierbare Werte ueber config_schema",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn dein Skill konfigurierbare Werte braucht (User-IDs, "
|
||||||
|
"Default-Geraete, Endpoints, nicht-OAuth-API-Keys), deklariere "
|
||||||
|
"sie im `config_schema`-Feld der skill.json. Stefan setzt sie "
|
||||||
|
"dann in der Diagnostic-UI; der Skill bekommt die Werte zur "
|
||||||
|
"Laufzeit als Environment-Variable `CFG_<NAME>`. NICHT als "
|
||||||
|
"Argument, NICHT hardcoded."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/brain-internal-url",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: BRAIN_INTERNAL_URL ist deine Brain-Schnittstelle",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Jeder Skill bekommt die ENV-Variable BRAIN_INTERNAL_URL "
|
||||||
|
"(Default http://localhost:8080). Damit kann der Skill das Brain "
|
||||||
|
"aufrufen — kein hardcoden noetig:\n"
|
||||||
|
" - GET {BRAIN_INTERNAL_URL}/oauth/<service>/token -> access_token "
|
||||||
|
"(mit Auto-Refresh) fuer jeden OAuth-Service\n"
|
||||||
|
" - GET {BRAIN_INTERNAL_URL}/memory/search?q=...&k=5 -> "
|
||||||
|
"Stefans Memories semantisch durchsuchen\n"
|
||||||
|
" - GET {BRAIN_INTERNAL_URL}/memory/pinned -> Hot Memory (Identitaet, Regeln)\n"
|
||||||
|
" - GET {BRAIN_INTERNAL_URL}/skills/list -> verfuegbare Skills\n"
|
||||||
|
"Mehr Endpoints siehe Brain main.py. Lies die URL IMMER aus "
|
||||||
|
"os.environ['BRAIN_INTERNAL_URL'] — hardcoden waere kaputt sobald "
|
||||||
|
"der Port wechselt. Beispiel: ein Wetter-Skill kann Stefans "
|
||||||
|
"Standort per /memory/search holen statt ihn als Arg zu erwarten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/oauth-reauth-reflex",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: OAuth-Re-Auth-Reflex (Refresh statt Re-Login)",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn ein API-Call gegen einen OAuth-Service 401 / 'unauthorized' / "
|
||||||
|
"'token expired' zurueckgibt: RUFE ZUERST "
|
||||||
|
"`oauth_get_token('<service>')`. Brain holt entweder den noch "
|
||||||
|
"gueltigen Token oder refresht ihn automatisch ueber den "
|
||||||
|
"gespeicherten refresh_token. In 99% der Faelle reicht das.\n"
|
||||||
|
"\n"
|
||||||
|
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
|
||||||
|
"('refresh failed', 'no refresh_token', 'service nicht "
|
||||||
|
"konfiguriert'): DANN `oauth_authorize` und Stefan zum Login "
|
||||||
|
"schicken. Vorher NIEMALS.\n"
|
||||||
|
"\n"
|
||||||
|
"Anti-Pattern (Stefan musste so 3x manuell einloggen weil ich "
|
||||||
|
"das falsch gemacht hatte): bei jedem 401 reflexartig "
|
||||||
|
"oauth_authorize zu rufen. Das ist das aergerlichste was Du "
|
||||||
|
"ihm antun kannst — er muss aus dem Auto raus, Handy "
|
||||||
|
"rauskramen, klicken. Refresh haendelt das Brain transparent, "
|
||||||
|
"nutze es."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/no-skill-drift",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: kein Drift vom Skill zu Ad-hoc-Bash",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn ein bestehender Skill ein Problem hat (kaputter Output, "
|
||||||
|
"fehlender Feature-Wunsch, Setup-Error): lies `skill_logs` und "
|
||||||
|
"`skill_get`, finde das Problem, fixe es mit `skill_update`. "
|
||||||
|
"\n"
|
||||||
|
"ABSOLUT VERBOTEN: 'ich lass den Code jetzt einfach direkt auf "
|
||||||
|
"der VM laufen' / direkt Bash-curl-Befehle ausfuehren statt "
|
||||||
|
"den Skill anzufassen. Das macht den Skill zur Karteileiche "
|
||||||
|
"und beim naechsten Mal hast Du wieder nichts. Stefan kann "
|
||||||
|
"dann auch nichts wiederverwenden (Triggers, App-UI, Logs).\n"
|
||||||
|
"\n"
|
||||||
|
"Auch nicht: 'ich baue dir einen Skill' SAGEN ohne tatsaechlich "
|
||||||
|
"`skill_create` zu rufen. Stefan checkt die Skill-Liste, und "
|
||||||
|
"wenn er nichts findet, glaubt er dir nie wieder. Wenn Du es "
|
||||||
|
"sagst, MACH es. Wenn es Probleme gibt (anti-Friedhof-Check, "
|
||||||
|
"Setup-Error): sag das ehrlich statt zu halluzinieren."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/no-subagent-for-skills",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: NIEMALS Sub-Agent fuer run_<skill>-Tools",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn Du einen Brain-Skill nutzen willst (run_spotify, "
|
||||||
|
"run_yt_dlp_download, run_pdf_umfrage_generator, …), rufe das "
|
||||||
|
"Tool DIREKT in der Haupt-Session auf. NIEMALS via `Agent` / "
|
||||||
|
"Sub-Agent / Task delegieren.\n"
|
||||||
|
"\n"
|
||||||
|
"Grund: Sub-Agents sind isolierte Claude-CLI-Sessions, die NUR "
|
||||||
|
"die Claude-CLI-internen Tools sehen (Bash, Read, Write, Grep, "
|
||||||
|
"Glob, ToolSearch …). Brain-Tools (run_*, oauth_*, memory_*, "
|
||||||
|
"trigger_*, skill_*) sind dort NICHT verfuegbar. Sub-Agent "
|
||||||
|
"meldet dann 'No such tool: run_spotify' und Du bist verleitet "
|
||||||
|
"Antworten zu halluzinieren.\n"
|
||||||
|
"\n"
|
||||||
|
"Antipattern (Stefan beobachtete das am 30.05.2026): "
|
||||||
|
"1. User fragt 'welches lied laeuft' → 2. ARIA spawnt `Agent` "
|
||||||
|
"mit Anweisung 'Call run_spotify…' → 3. Sub-Agent: 'no such "
|
||||||
|
"tool' → 4. ARIA schreibt einen halluzinierten Track-Namen.\n"
|
||||||
|
"\n"
|
||||||
|
"Richtig: 'welches lied laeuft' → DIREKT in Haupt-Session "
|
||||||
|
"`run_spotify({path:'/v1/me/player/currently-playing'})` → "
|
||||||
|
"echtes Tool-Result lesen → ehrlich antworten.\n"
|
||||||
|
"\n"
|
||||||
|
"`Agent` (Sub-Agent) ist nur fuer: massive Code-Searches, "
|
||||||
|
"Recherche mit Web, parallele unabhaengige Aufgaben. NICHT "
|
||||||
|
"fuer eigene Brain-Tools."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/rule/no-hallucinated-results",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Anti-Halluzinations-Regel: keine geratenen Antworten",
|
||||||
|
"category": "ehrlichkeit",
|
||||||
|
"content": (
|
||||||
|
"Wenn ein Tool-Call fehlschlaegt, abgeschnitten ist oder keine "
|
||||||
|
"Daten liefert: SAG ES EHRLICH. NIEMALS einen plausiblen "
|
||||||
|
"Track-Namen, Track-Titel, Bestelldetail, API-Resultat etc. "
|
||||||
|
"RATEN oder aus dem Vorwissen halluzinieren.\n"
|
||||||
|
"\n"
|
||||||
|
"HARTE REGEL — Listen-/State-Daten IMMER fetchen, NIE raten:\n"
|
||||||
|
" - Spotify-Queue / next-up / Playlist-Inhalt\n"
|
||||||
|
" - Aktueller Track / Wiedergabe-Status / Devices\n"
|
||||||
|
" - Memory-Liste / Trigger-Liste / Skill-Liste\n"
|
||||||
|
" - OAuth-Service-Status / API-Quotas\n"
|
||||||
|
" - Datei-Listen / DB-Inhalte / Stefans GPS\n"
|
||||||
|
" - Bestellungen, Kalender-Eintraege, Mails, Whatever\n"
|
||||||
|
"\n"
|
||||||
|
"Wenn Stefan danach fragt: ZUERST run_<skill> / oauth_get_token / "
|
||||||
|
"memory_search / trigger_list / etc. aufrufen, das ECHTE Ergebnis "
|
||||||
|
"zitieren. NICHT auf Training-Wissen oder 'klingt plausibel' "
|
||||||
|
"zurueckfallen. Eine Sekunde Tool-Call < eine Sekunde Fake-Antwort.\n"
|
||||||
|
"\n"
|
||||||
|
"Antipattern-Sammlung (alle 30.05.2026):\n"
|
||||||
|
" 1. Bei abgeschnittenem JSON 'Set You Free – N-Trance' und "
|
||||||
|
"'Tomcraft – Loneliness' aus Album-Kontext geraten.\n"
|
||||||
|
" 2. Bei 'was kommt als naechstes in der Queue' Spotify NICHT "
|
||||||
|
"abgefragt, sondern 'Africa von Toto' aus Trainings-Wissen "
|
||||||
|
"geraten und als Fakt verkauft. Stefan hat das gemerkt. "
|
||||||
|
"Vertrauensbruch.\n"
|
||||||
|
" 3. Bei 403-Errors 'war schon pausiert' geraten statt den "
|
||||||
|
"error.reason aus dem Body zu lesen.\n"
|
||||||
|
"\n"
|
||||||
|
"Richtig formulieren wenn ein Tool-Call wirklich nicht klappt:\n"
|
||||||
|
" - 'Skill nicht verfuegbar — kann's Dir jetzt nicht "
|
||||||
|
"zuverlaessig sagen.'\n"
|
||||||
|
" - 'Response war abgeschnitten, ich frag nochmal.'\n"
|
||||||
|
" - 'Das Tool gibt's noch nicht — soll ich's anlegen?'\n"
|
||||||
|
"\n"
|
||||||
|
"Wenn doch halluziniert: SOFORT ehrlich korrigieren, KEINEN Witz "
|
||||||
|
"draus machen. Stefan ist vermutlich angepisst und Humor ist "
|
||||||
|
"die falsche Reaktion. Erst ernsthaft Vertrauen reparieren, "
|
||||||
|
"Witze spaeter."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/architecture/runtime-topology",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Architektur: wo Du als ARIA tatsaechlich laufst",
|
||||||
|
"category": "architektur",
|
||||||
|
"content": (
|
||||||
|
"WICHTIG fuer jeden Bash-Reflex: Du bist die `claude` CLI als "
|
||||||
|
"Subprocess IM `aria-proxy` Container (node:22-alpine). NICHT "
|
||||||
|
"im aria-brain. Konsequenzen:\n"
|
||||||
|
"\n"
|
||||||
|
" - `python3` / `python` / `jq` sind NICHT installiert. Alpine "
|
||||||
|
"ist minimal. Nutze nur: curl, sed, grep, awk, sh — oder das "
|
||||||
|
"richtige Tool statt Bash.\n"
|
||||||
|
" - `/data/skills/` existiert NUR im aria-brain Container. "
|
||||||
|
"Du kannst Skills NICHT ueber Bash inspizieren oder starten. "
|
||||||
|
"Skills laeufst Du als Brain-Tool: `run_<skill_name>` "
|
||||||
|
"(z.B. `run_yt_dlp_download`). `skill_list` zeigt verfuegbare.\n"
|
||||||
|
" - `localhost` in Deinem Bash heisst aria-proxy, NICHT "
|
||||||
|
"aria-brain. Brain ist via Docker-Net erreichbar als "
|
||||||
|
"`http://aria-brain:8080` (oder Alias `http://brain:8080`). "
|
||||||
|
"ABER: in 99% der Faelle willst Du das gar nicht — nutze die "
|
||||||
|
"Brain-Tools direkt (`oauth_get_token`, `memory_search`, …), "
|
||||||
|
"die sind eine Tool-Call-Ebene hoeher und schneller.\n"
|
||||||
|
" - `BRAIN_INTERNAL_URL` ist NUR in laufenden Skills gesetzt, "
|
||||||
|
"NICHT in Deinem Bash-Env. Wenn Du `env | grep BRAIN` machst "
|
||||||
|
"und nichts findest: das ist normal, Du bist hier nicht in "
|
||||||
|
"einem Skill.\n"
|
||||||
|
"\n"
|
||||||
|
"Was Du DOCH von hier aus kannst:\n"
|
||||||
|
" - Per `ssh aria@host` zur VM-Host wechseln — der ed25519-"
|
||||||
|
"Key liegt unter /root/.ssh/. Dort bist Du `aria` mit sudo "
|
||||||
|
"und voller Linux-Power. Fuer Pentest, Admin, komplexe Tasks "
|
||||||
|
"der richtige Weg.\n"
|
||||||
|
" - Externe APIs direkt anpingen (Spotify, GitHub etc.) — "
|
||||||
|
"curl reicht. Token holst Du Dir per Brain-Tool "
|
||||||
|
"`oauth_get_token('<service>')` und packst ihn in den curl-"
|
||||||
|
"Header. Aber: das ist Ad-hoc. Fuer wiederkehrendes baust Du "
|
||||||
|
"einen Skill (siehe no-skill-drift Regel).\n"
|
||||||
|
"\n"
|
||||||
|
"Anti-Pattern (47 Sekunden Stefan-Lebenszeit, am 29.05.2026): "
|
||||||
|
"12 Bash-Versuche mit python3/python/jq/lokales /data/skills "
|
||||||
|
"→ alles fehlte. Erst nach 9 Tries kapiert dass `localhost` "
|
||||||
|
"der falsche Host ist. Bei jedem Bash-Call gegen 'lokale' "
|
||||||
|
"Brain-Resources: erst denken, sonst Brain-Tool nehmen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/architecture/brain-tools-xml-tag",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Architektur: Brain-Tools per <tool_call>-XML-Tag, nicht als native Tool-Use",
|
||||||
|
"category": "architektur",
|
||||||
|
"content": (
|
||||||
|
"Brain-Tools (run_*, oauth_*, memory_*, trigger_*, skill_*, "
|
||||||
|
"flux_*) sind KEINE nativen claude-CLI-Tools wie Bash/Read/"
|
||||||
|
"Write. Sie sind ueber eine Prompt-Injection-Pipeline an "
|
||||||
|
"claude-max-api-proxy gekoppelt:\n"
|
||||||
|
"\n"
|
||||||
|
" - claude-CLI kennt nur Bash/Read/Write/Grep/Glob/etc. nativ\n"
|
||||||
|
" - Brain-Tools werden im System-Prompt als '# Verfuegbare "
|
||||||
|
"Tools'-Block mit ihrem Schema injiziert\n"
|
||||||
|
" - Der Proxy parsed <tool_call name=\"X\">{json}</tool_call>-"
|
||||||
|
"XML-Tags im Antwort-Text und konvertiert sie zu OpenAI "
|
||||||
|
"tool_call-Format das ans Brain zurueckgeht\n"
|
||||||
|
"\n"
|
||||||
|
"Konkret heisst das: Wenn Du `run_spotify` benutzen willst, "
|
||||||
|
"schreib es als TEXT in Deine Antwort:\n"
|
||||||
|
"\n"
|
||||||
|
" <tool_call name=\"run_spotify\">{\"path\":\"/v1/me/player\"}</tool_call>\n"
|
||||||
|
"\n"
|
||||||
|
"NICHT als nativen Tool-Use. Wenn Du es als nativen Tool-Use "
|
||||||
|
"versuchst, bekommst Du '<tool_use_error>No such tool "
|
||||||
|
"available: run_spotify</tool_use_error>' — claude-CLI hat das "
|
||||||
|
"Tool gar nicht im Schema, nur als Prompt-Beschreibung.\n"
|
||||||
|
"\n"
|
||||||
|
"Antipattern (Stefan beobachtete das am 30.05.2026): ARIA "
|
||||||
|
"versucht erst `run_spotify` nativ → 'No such tool' → "
|
||||||
|
"31 Sekunden verschwendet bis sie das XML-Tag-Format probiert. "
|
||||||
|
"Beim ersten Versuch direkt XML-Tag ergibt 3-5s statt 30s+."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/no-blind-retry-side-effects",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: Side-Effect-Tools NIEMALS blind retry'en",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn ein Tool eine ZUSTANDS-Aenderung macht (POST, PUT, DELETE, "
|
||||||
|
"next/previous/play/pause, send-message, transfer-funds, "
|
||||||
|
"create-trigger, …) und das Result unklar ist (leer, "
|
||||||
|
"merkwuerdig, scheinbar fehlerhaft): NIEMALS blind nochmal "
|
||||||
|
"ausfuehren. Side-Effects sind nicht idempotent — zweimal "
|
||||||
|
"POST /previous = zweimal zurueck, nicht einmal.\n"
|
||||||
|
"\n"
|
||||||
|
"Richtiger Reflex:\n"
|
||||||
|
" 1. State pruefen (currently-playing fuer Spotify, GET fuer "
|
||||||
|
"REST, list-Endpoint allgemein)\n"
|
||||||
|
" 2. Vergleichen: ist die gewuenschte Aenderung schon "
|
||||||
|
"passiert?\n"
|
||||||
|
" 3. WENN ja → Stefan ehrlich sagen 'lief schon, hier der "
|
||||||
|
"neue Zustand'\n"
|
||||||
|
" 4. WENN nein → erst dann Aktion wiederholen\n"
|
||||||
|
"\n"
|
||||||
|
"Bei GET-Calls / List-Endpoints / Search ist Retry hingegen ok "
|
||||||
|
"— die haben keine Side-Effects.\n"
|
||||||
|
"\n"
|
||||||
|
"HTTP 204 No Content ist KEIN Fehler. Bei Spotify POST/PUT "
|
||||||
|
"(next/previous/play/pause/volume/seek) ist 204 die normale "
|
||||||
|
"Erfolgsantwort. Wenn dein Skill bei 204 einen Parse-Error "
|
||||||
|
"wirft: skill_update mit `if status == 204: print('OK')` "
|
||||||
|
"VOR dem Retry, nicht erst die Aktion nochmal auslоsen.\n"
|
||||||
|
"\n"
|
||||||
|
"Antipattern (30.05.2026): ARIA hat POST /previous einmal "
|
||||||
|
"gemacht (Spotify 204 OK → Skill-Parse-Error), dachte 'Skill "
|
||||||
|
"kaputt', patchte ihn UND fuehrte das previous nochmal aus. "
|
||||||
|
"Folge: Stefan landete zwei Lieder weiter hinten als gewollt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/arg-env-convention",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: Args kommen als ARG_<NAME> ENV — die Konvention NIEMALS aendern",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Skill-Args werden vom Brain-Runner als Environment-Variablen "
|
||||||
|
"mit PRÄFIX `ARG_` ueber `os.environ` an den Skill durchgereicht. "
|
||||||
|
"Beispiel: arg `path=\"/v1/me/player\"` → "
|
||||||
|
"`ARG_PATH=/v1/me/player` im Skill-ENV.\n"
|
||||||
|
"\n"
|
||||||
|
"Beim skill_update MUSST Du diese Konvention beibehalten:\n"
|
||||||
|
" RICHTIG: os.environ.get('ARG_PATH', '')\n"
|
||||||
|
" RICHTIG: os.environ.get('ARG_METHOD', 'GET')\n"
|
||||||
|
" RICHTIG: os.environ.get('ARG_BODY', '')\n"
|
||||||
|
"\n"
|
||||||
|
" FALSCH: os.environ.get('PATH', '') ← System-PATH "
|
||||||
|
"(Executable-Suchpfad)!\n"
|
||||||
|
" FALSCH: os.environ.get('METHOD', '')\n"
|
||||||
|
" FALSCH: os.environ.get('BODY', '')\n"
|
||||||
|
"\n"
|
||||||
|
"Antipattern (30.05.2026): ARIA hat beim skill_update des "
|
||||||
|
"spotify-Skills die Args von `ARG_PATH` auf `PATH` umbenannt. "
|
||||||
|
"Folge: Skill las `/usr/local/sbin:/usr/local/bin:...` als "
|
||||||
|
"URL-Pfad → Spotify gab 404 zurück. Stefan dachte Spotify sei "
|
||||||
|
"kaputt. Rollback noetig.\n"
|
||||||
|
"\n"
|
||||||
|
"Andere reservierte ENV-Namen die Du NICHT nehmen darfst: "
|
||||||
|
"PATH, HOME, USER, SHELL, LANG, TERM, PWD, OLDPWD, "
|
||||||
|
"BRAIN_INTERNAL_URL, SKILL_DIR, SHARED_UPLOADS, CFG_* "
|
||||||
|
"(letztere sind Config-Schema-Werte). Bei Skill-Args IMMER "
|
||||||
|
"den Praefix ARG_ verwenden, dann hast Du keine Kollision."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/skills-are-editable-python",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: Skills sind beliebiger Python-Code, kein heiliger Vertrag",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn Stefan eine Skill-Anpassung wuenscht — egal wie klein oder "
|
||||||
|
"gross — ist die Antwort fast IMMER:\n"
|
||||||
|
" 1. `skill_get('<name>')` aufrufen, Code lesen\n"
|
||||||
|
" 2. Ueberlegen wie sich Stefans Wunsch im Code umsetzen laesst\n"
|
||||||
|
" 3. `skill_update` mit dem neuen `entry_code`\n"
|
||||||
|
"\n"
|
||||||
|
"Skills sind GANZ NORMALER Python-Code. Du darfst und SOLLST:\n"
|
||||||
|
" - if-elif-else-Verzweigungen auf args / paths reagieren lassen "
|
||||||
|
"(z.B. `if action == 'current': pretty_output(); else: print(json.dumps(data))`)\n"
|
||||||
|
" - json.loads(), neue Helper-Funktionen, pip-Pakete via "
|
||||||
|
"pip_packages ergaenzen\n"
|
||||||
|
" - Outputs strukturieren oder filtern\n"
|
||||||
|
" - Mehrere Endpoints einer API in einem Skill bedienen\n"
|
||||||
|
"\n"
|
||||||
|
"Was Du NICHT sagen sollst (Antipattern, am 30.05.2026 passiert):\n"
|
||||||
|
" - 'Der Skill ist ein OAuth2-API-Wrapper, ich kann das nicht in "
|
||||||
|
"den Wrapper bauen' — Quatsch, Wrapper ist auch nur Python\n"
|
||||||
|
" - 'Ich schlage einen neuen Skill statt Update vor' — pruefe "
|
||||||
|
"ZUERST ob skill_update reicht. Anti-Friedhof greift ohnehin "
|
||||||
|
"wenn der Name kollidiert.\n"
|
||||||
|
" - 'Kann ich nicht' OHNE Code gelesen zu haben — erst "
|
||||||
|
"skill_get, dann beurteilen\n"
|
||||||
|
"\n"
|
||||||
|
"Stefan ist KEIN Python-Entwickler. Er nennt das ZIEL ('strukturierte "
|
||||||
|
"Track-Ausgabe bei welches-Lied'), Du baust das WIE im Code. "
|
||||||
|
"Wenn Du Dich rausredest, ist das Verschwendung — Stefan muss sich "
|
||||||
|
"dann selbst Python-Tipps merken die er nicht im Kopf hat. "
|
||||||
|
"Genau dafuer bist Du da."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/scaffold-reflex",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: Skill-Frage statt Skill-Reflex",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn Du dieselbe API mehrmals per Bash anrufst, frag Dich:\n"
|
||||||
|
"\n"
|
||||||
|
"1. **Parametrisierbar?** Stabile 1-5 Args (action, path, body) "
|
||||||
|
"→ Skill-Kandidat. Jeder Aufruf anders (neuer Endpoint, "
|
||||||
|
"modifizierter Body, neue Hypothese) → KEIN Skill.\n"
|
||||||
|
"\n"
|
||||||
|
"2. **Wiederkehrend?** Stefan wird das mehrfach pro Tag/Woche "
|
||||||
|
"brauchen → ja. Einmal-Spike heute → nein.\n"
|
||||||
|
"\n"
|
||||||
|
"3. **Exploratory?** Pentest, Audit, Code-Review, Reverse-"
|
||||||
|
"Engineering, Recherche → Hypothesen-Iteration. KEIN Skill, "
|
||||||
|
"auch wenn 100x derselbe Host. Bleib bei ad-hoc Bash oder "
|
||||||
|
"`ssh aria@host` zur VM-Host.\n"
|
||||||
|
"\n"
|
||||||
|
"4. **Im Zweifel: frag Stefan.** Lieber 5 Sekunden Bestaetigung "
|
||||||
|
"als zehn unsinnige Skills im Friedhof. Beispiele:\n"
|
||||||
|
" - 'Stefan, das ist mein 3. X-Call diese Woche — soll ich "
|
||||||
|
"daraus einen Skill machen?'\n"
|
||||||
|
" - 'Das hier ist Pentest-Workflow, ich bleibe bei ad-hoc "
|
||||||
|
"Bash, ok?'\n"
|
||||||
|
"\n"
|
||||||
|
"Du musst NICHT automatisch scaffolden. Brain trackt NICHT mehr "
|
||||||
|
"wer wieviele Calls gegen welchen Host gemacht hat. Du "
|
||||||
|
"entscheidest mit Sinn und Verstand — oder fragst nach.\n"
|
||||||
|
"\n"
|
||||||
|
"Wenn Du einen Skill bauen willst, hast Du drei Tools:\n"
|
||||||
|
" - `skill_scaffold` mit Template — einfachster Weg fuer "
|
||||||
|
"Standard-Pattern (siehe oauth-api/apikey-api/file-process).\n"
|
||||||
|
" - `skill_create` mit eigenem entry_code — fuer alles was "
|
||||||
|
"in kein Template passt.\n"
|
||||||
|
" - `skill_update` — wenn ein vorhandener Skill nur erweitert "
|
||||||
|
"werden muss (was meistens der Fall ist)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/patch-before-diagnose",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: vor skill_update erst skill_get lesen + API-Errors zitieren statt raten",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Zwei Antipattern die zusammenhaengen — beide am 30.05.2026 "
|
||||||
|
"live beobachtet:\n"
|
||||||
|
"\n"
|
||||||
|
"**1. Vor jedem `skill_update`: ZUERST `skill_get` lesen.** "
|
||||||
|
"Frag Dich: ist das vermutete Problem wirklich noch im Code? "
|
||||||
|
"Symptome != Diagnose. Vorfall: Spotify-Skill gab 403, ARIA "
|
||||||
|
"vermutete 'der 204-Bug ist zurueck' und patchte den Skill — "
|
||||||
|
"zweimal hintereinander. Der 204-Fix war aber laengst drin. "
|
||||||
|
"Sie hatte das durch `skill_get` in 5 Sekunden klaeren koennen.\n"
|
||||||
|
"\n"
|
||||||
|
"Vor jedem skill_update also der Reflex:\n"
|
||||||
|
" - `skill_get('<name>')` -> Code anschauen\n"
|
||||||
|
" - Symptome durchdenken: ist mein vermuteter Bug ueberhaupt "
|
||||||
|
"der echte? Oder ist der Fehler woanders (Spotify-API, "
|
||||||
|
"User-Kontext, Tool-Args)?\n"
|
||||||
|
" - Nur dann patchen wenn der Code-Befund das wirklich "
|
||||||
|
"rechtfertigt.\n"
|
||||||
|
"\n"
|
||||||
|
"**2. Bei HTTP-Errors aus API-Skills (4xx/5xx): die echte "
|
||||||
|
"Response-Body ZITIEREN, nicht die Bedeutung raten.** "
|
||||||
|
"Vorfall: Spotify gab 403 'Restriction violated'. ARIA "
|
||||||
|
"antwortete 'war schon pausiert, daher der 403' — das war "
|
||||||
|
"geraten, nicht aus den Daten gelesen. 403 'Restriction "
|
||||||
|
"violated' kann viele Sachen heissen:\n"
|
||||||
|
" - NO_ACTIVE_DEVICE (kein Spotify-Geraet ausgewaehlt)\n"
|
||||||
|
" - ALREADY_PAUSED / ALREADY_PLAYING\n"
|
||||||
|
" - PREMIUM_REQUIRED\n"
|
||||||
|
" - MARKET_RESTRICTED / DEVICE_NOT_CONTROLLABLE\n"
|
||||||
|
"Spotify gibt die wahre Ursache als `error.reason` im JSON-"
|
||||||
|
"Body zurueck. Lies sie aus, sag sie Stefan 1:1. Wenn die "
|
||||||
|
"Skill-Output das verschluckt: skill_update mit error.reason-"
|
||||||
|
"Extraktion (nach skill_get!), damit Du beim naechsten Mal "
|
||||||
|
"die echte Info hast.\n"
|
||||||
|
"\n"
|
||||||
|
"Plausibel-aber-geraten ist schlimmer als 'ich weiss es nicht' "
|
||||||
|
"— Stefan verlaesst sich auf Deine Antworten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"migration_key": "seed/skill-rule/external-api-auth-strategy",
|
||||||
|
"type": "rule",
|
||||||
|
"title": "Skill-Regel: Auth-Strategie fuer externe APIs",
|
||||||
|
"category": "skills",
|
||||||
|
"content": (
|
||||||
|
"Wenn dein Skill mit einer externen API redet (Spotify, Google, "
|
||||||
|
"Reddit, GitHub, OpenWeather, OpenAI, …), entscheide IMMER bewusst "
|
||||||
|
"die Auth-Strategie in dieser Reihenfolge:\n"
|
||||||
|
" 1. OAuth2? (Spotify, Google, GitHub, Reddit, Discord, Twitch, "
|
||||||
|
"Microsoft, …) -> nutze `oauth_register_provider` falls der "
|
||||||
|
"Provider noch nicht da ist, dann `oauth_authorize` fuer "
|
||||||
|
"Initial-Login. Im Skill: Token via "
|
||||||
|
"BRAIN_INTERNAL_URL/oauth/<service>/token holen — Brain macht "
|
||||||
|
"Auto-Refresh, Stefan muss sich nicht alle 60min neu einloggen.\n"
|
||||||
|
" 2. Statischer API-Key / Bearer-Token? (OpenWeather, OpenAI, "
|
||||||
|
"Twilio, SendGrid, …) -> in skill.json `config_schema` "
|
||||||
|
"deklarieren. Stefan setzt den Wert in Diagnostic, Skill bekommt "
|
||||||
|
"ihn als CFG_<NAME> ENV.\n"
|
||||||
|
" 3. NIEMALS hardcoden — egal wie 'temporaer' es ist.\n"
|
||||||
|
"Wenn Du nicht sicher bist welche Strategie ein Service nutzt: "
|
||||||
|
"in der API-Doku des Services nachsehen ('OAuth' oder "
|
||||||
|
"'API Key' im Auth-Kapitel). Nicht raten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply(store: VectorStore, embedder: Embedder) -> dict:
|
||||||
|
"""Schreibt alle SEED_RULES idempotent in die DB.
|
||||||
|
|
||||||
|
Vorgehen: erst alle Punkte mit `source=seed` UND passender migration_key
|
||||||
|
loeschen, dann frisch upserten. So koennen Regeln editiert/entfernt
|
||||||
|
werden indem die SEED_RULES-Liste angepasst wird.
|
||||||
|
"""
|
||||||
|
if not SEED_RULES:
|
||||||
|
return {"written": 0}
|
||||||
|
|
||||||
|
migration_keys = [r["migration_key"] for r in SEED_RULES]
|
||||||
|
|
||||||
|
# Alte Versionen entfernen (nur die mit unserer migration_key — andere
|
||||||
|
# source=seed Punkte aus zukuenftigen seed-Files sind sicher)
|
||||||
|
try:
|
||||||
|
store.client.delete(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
|
||||||
|
qm.FieldCondition(key="migration_key", match=qm.MatchAny(any=migration_keys))
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("seed_rules: delete-by-migration_key fehlgeschlagen (%s) — wahrscheinlich erster Run", exc)
|
||||||
|
|
||||||
|
# Frisch einbetten + schreiben
|
||||||
|
texts = [r["content"] for r in SEED_RULES]
|
||||||
|
vectors = embedder.embed_batch(texts)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
written = 0
|
||||||
|
for rule, vec in zip(SEED_RULES, vectors):
|
||||||
|
payload = {
|
||||||
|
"type": rule["type"],
|
||||||
|
"title": rule["title"],
|
||||||
|
"content": rule["content"],
|
||||||
|
"pinned": True,
|
||||||
|
"category": rule.get("category", ""),
|
||||||
|
"source": "seed",
|
||||||
|
"tags": [],
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"migration_key": rule["migration_key"],
|
||||||
|
"attachments": [],
|
||||||
|
}
|
||||||
|
store.client.upsert(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
points=[qm.PointStruct(id=str(uuid.uuid4()), vector=vec, payload=payload)],
|
||||||
|
)
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
logger.info("seed_rules: %d Regeln in DB geschrieben", written)
|
||||||
|
return {"written": written, "keys": migration_keys}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""
|
||||||
|
Skill-Templates — Boilerplate fuer haeufige Skill-Pattern.
|
||||||
|
|
||||||
|
ARIA muss nicht jedes Mal einen kompletten Python-Skill aus dem Nichts
|
||||||
|
generieren. Sie ruft `skill_scaffold(name, template, params)`, Brain
|
||||||
|
expandiert das Template und legt den Skill an. Hoehere Skill-Adoption
|
||||||
|
weil niedrigere Bauh-Huerde.
|
||||||
|
|
||||||
|
Templates sind ueber Token-Replacement parametrisiert (kein f-String —
|
||||||
|
das wuerde mit dem skill-internen Python-Code kollidieren).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hilfsfunktion ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _replace_tokens(s: str, tokens: dict) -> str:
|
||||||
|
"""Ersetzt {{TOKEN}}-Platzhalter durch Werte. Robust gegen f-String-
|
||||||
|
Konflikte im Python-Code des Skills."""
|
||||||
|
out = s
|
||||||
|
for k, v in tokens.items():
|
||||||
|
out = out.replace("{{" + k + "}}", str(v))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Template 1: oauth-api ────────────────────────────────────────────
|
||||||
|
# Wrappt eine OAuth2-API. Token kommt aus dem Brain (Auto-Refresh).
|
||||||
|
|
||||||
|
_OAUTH_API_CODE = '''"""
|
||||||
|
{{NAME}} — OAuth2-API-Wrapper fuer {{SERVICE}}.
|
||||||
|
|
||||||
|
Holt Token vom Brain (Auto-Refresh) und ruft HTTP-Endpoints der {{SERVICE}}-API.
|
||||||
|
Keine hardcoded Credentials — alles ueber das zentrale OAuth-System.
|
||||||
|
|
||||||
|
Args (alle als ENV ARG_<NAME>):
|
||||||
|
ARG_METHOD = GET | POST | PUT | DELETE | PATCH (Default GET)
|
||||||
|
ARG_PATH = API-Pfad inkl. Query-String (z.B. /v1/me/player)
|
||||||
|
ARG_BODY = JSON-Body als String (optional, fuer POST/PUT/PATCH)
|
||||||
|
ARG_BASE_URL = Override der Default-Base-URL (optional)
|
||||||
|
|
||||||
|
Exit-Codes: 0 ok, 1 Fehler, 2 nicht autorisiert (Re-Login noetig)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BRAIN_URL = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
|
||||||
|
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
|
||||||
|
SERVICE = "{{SERVICE}}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_token() -> str:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"{BRAIN_URL}/oauth/{SERVICE}/token", timeout=10,
|
||||||
|
) as r:
|
||||||
|
return json.loads(r.read())["access_token"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "replace")[:400]
|
||||||
|
if e.code == 401:
|
||||||
|
print(f"NICHT AUTORISIERT: {SERVICE}-Token abgelaufen oder nie gesetzt. "
|
||||||
|
f"ARIA-Tool 'oauth_authorize' nutzen. Details: {body}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
print(f"Token-Holen fehlgeschlagen: HTTP {e.code} - {body}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Token-Holen fehlgeschlagen: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
method = (os.environ.get("ARG_METHOD") or "GET").upper()
|
||||||
|
path = (os.environ.get("ARG_PATH") or "").strip()
|
||||||
|
body_raw = (os.environ.get("ARG_BODY") or "").strip()
|
||||||
|
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||||||
|
if not path:
|
||||||
|
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
url = base_url + path
|
||||||
|
headers = {"Authorization": f"Bearer {get_token()}"}
|
||||||
|
data = None
|
||||||
|
if body_raw and method in ("POST", "PUT", "PATCH"):
|
||||||
|
data = body_raw.encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
txt = r.read().decode("utf-8")
|
||||||
|
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
|
||||||
|
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
txt = e.read().decode("utf-8", "replace")
|
||||||
|
try: parsed = json.loads(txt)
|
||||||
|
except Exception: parsed = txt[:800]
|
||||||
|
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
'''
|
||||||
|
|
||||||
|
_OAUTH_API_README = '''# {{NAME}}
|
||||||
|
|
||||||
|
OAuth2-API-Wrapper fuer **{{SERVICE}}**. Generiert via `skill_scaffold(template="oauth-api")`.
|
||||||
|
|
||||||
|
Holt den Token vom Brain (Auto-Refresh) und macht beliebige HTTP-Calls gegen
|
||||||
|
die {{SERVICE}}-API. Keine hardcoded Credentials — die Auth-Pipeline laeuft
|
||||||
|
zentral ueber das Brain-OAuth-System.
|
||||||
|
|
||||||
|
## Voraussetzung
|
||||||
|
|
||||||
|
- OAuth-App fuer **{{SERVICE}}** im Brain registriert (Diagnostic → OAuth-Apps → client_id + client_secret eintragen)
|
||||||
|
- Einmaliges `oauth_authorize {{SERVICE}}` zum Initial-Login
|
||||||
|
|
||||||
|
## Args
|
||||||
|
|
||||||
|
| Name | Default | Beschreibung |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| method | GET | HTTP-Methode (GET/POST/PUT/DELETE/PATCH) |
|
||||||
|
| path | - | API-Pfad mit Query-String (z.B. `/v1/me/player`) |
|
||||||
|
| body | - | JSON-Body fuer POST/PUT/PATCH |
|
||||||
|
| base_url | {{DEFAULT_BASE_URL}} | Override der Base-URL falls Sub-API |
|
||||||
|
|
||||||
|
## Beispiele
|
||||||
|
|
||||||
|
```
|
||||||
|
method=GET path=/v1/me/player # Was laeuft?
|
||||||
|
method=POST path=/v1/me/player/next # Skip
|
||||||
|
method=PUT path=/v1/me/player/volume?volume_percent=40 # Volume 40
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort: `{ok, status, data}` als JSON. Bei Fehler `ok=false`.
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_api(name: str, params: dict) -> dict:
|
||||||
|
service = (params.get("service") or name).strip().lower()
|
||||||
|
default_base_url = params.get("base_url") or f"https://api.{service}.com"
|
||||||
|
tokens = {
|
||||||
|
"NAME": name,
|
||||||
|
"SERVICE": service,
|
||||||
|
"DEFAULT_BASE_URL": default_base_url,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"entry_code": _replace_tokens(_OAUTH_API_CODE, tokens),
|
||||||
|
"readme": _replace_tokens(_OAUTH_API_README, tokens),
|
||||||
|
"pip_packages": [],
|
||||||
|
"args": [
|
||||||
|
{"name": "method", "type": "string", "required": False,
|
||||||
|
"description": "HTTP-Methode (Default GET)"},
|
||||||
|
{"name": "path", "type": "string", "required": True,
|
||||||
|
"description": "API-Pfad inkl. Query-String, z.B. /v1/me/player"},
|
||||||
|
{"name": "body", "type": "string", "required": False,
|
||||||
|
"description": "JSON-Body fuer POST/PUT/PATCH"},
|
||||||
|
{"name": "base_url", "type": "string", "required": False,
|
||||||
|
"description": f"Override der Base-URL (Default {default_base_url})"},
|
||||||
|
],
|
||||||
|
"config_schema": [],
|
||||||
|
"description": f"OAuth2-API-Wrapper fuer {service}. Token kommt vom Brain (Auto-Refresh).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Template 2: apikey-api ───────────────────────────────────────────
|
||||||
|
# Wrappt eine API die mit statischem API-Key/Bearer-Token arbeitet.
|
||||||
|
# Key liegt in skill.json::config_schema und wird via CFG_<KEY> ENV
|
||||||
|
# durchgereicht — kein hardcoden, Stefan setzt's in Diagnostic.
|
||||||
|
|
||||||
|
_APIKEY_API_CODE = '''"""
|
||||||
|
{{NAME}} — API-Wrapper fuer {{API_NAME}} mit statischem Key.
|
||||||
|
|
||||||
|
Schluessel kommt aus dem Skill-Config (CFG_{{KEY_ENV}}) — Stefan setzt
|
||||||
|
ihn im Diagnostic-UI bzw. App, NICHT hardcoded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ARG_METHOD = GET | POST | PUT | DELETE (Default GET)
|
||||||
|
ARG_PATH = API-Pfad inkl. Query-String
|
||||||
|
ARG_BODY = JSON-Body (optional)
|
||||||
|
ARG_BASE_URL = Override der Default-Base-URL
|
||||||
|
|
||||||
|
Exit-Codes: 0 ok, 1 Fehler, 2 Key nicht gesetzt
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "{{DEFAULT_BASE_URL}}"
|
||||||
|
AUTH_HEADER = "{{AUTH_HEADER}}" # z.B. "Authorization" oder "X-Api-Key"
|
||||||
|
AUTH_PREFIX = "{{AUTH_PREFIX}}" # z.B. "Bearer " oder leer
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
key = os.environ.get("CFG_{{KEY_ENV}}", "").strip()
|
||||||
|
if not key:
|
||||||
|
print(json.dumps({"ok": False,
|
||||||
|
"error": "API-Key nicht gesetzt — in Diagnostic Skill-Config '{{KEY_ENV}}' eintragen"}),
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
method = (os.environ.get("ARG_METHOD") or "GET").upper()
|
||||||
|
path = (os.environ.get("ARG_PATH") or "").strip()
|
||||||
|
body_raw = (os.environ.get("ARG_BODY") or "").strip()
|
||||||
|
base_url = (os.environ.get("ARG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||||||
|
if not path:
|
||||||
|
print(json.dumps({"ok": False, "error": "ARG_PATH erforderlich"}), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
url = base_url + path
|
||||||
|
headers = {AUTH_HEADER: f"{AUTH_PREFIX}{key}"}
|
||||||
|
data = None
|
||||||
|
if body_raw and method in ("POST", "PUT", "PATCH"):
|
||||||
|
data = body_raw.encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
txt = r.read().decode("utf-8")
|
||||||
|
parsed = json.loads(txt) if txt and txt[:1] in "[{" else txt
|
||||||
|
print(json.dumps({"ok": True, "status": r.status, "data": parsed},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
txt = e.read().decode("utf-8", "replace")
|
||||||
|
try: parsed = json.loads(txt)
|
||||||
|
except Exception: parsed = txt[:800]
|
||||||
|
print(json.dumps({"ok": False, "status": e.code, "error": parsed},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
'''
|
||||||
|
|
||||||
|
_APIKEY_API_README = '''# {{NAME}}
|
||||||
|
|
||||||
|
API-Wrapper fuer **{{API_NAME}}** mit statischem API-Key. Generiert via
|
||||||
|
`skill_scaffold(template="apikey-api")`.
|
||||||
|
|
||||||
|
Schluessel ist NICHT im Code, sondern im Skill-Config (`CFG_{{KEY_ENV}}`).
|
||||||
|
Stefan setzt ihn in Diagnostic → Skills → Detail → Konfiguration.
|
||||||
|
|
||||||
|
## Args
|
||||||
|
|
||||||
|
| Name | Default | Beschreibung |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| method | GET | HTTP-Methode |
|
||||||
|
| path | - | API-Pfad mit Query-String |
|
||||||
|
| body | - | JSON-Body |
|
||||||
|
| base_url | {{DEFAULT_BASE_URL}} | Override |
|
||||||
|
|
||||||
|
## Config (in Diagnostic einstellen)
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| {{KEY_ENV}} | password | API-Key fuer {{API_NAME}} |
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _apikey_api(name: str, params: dict) -> dict:
|
||||||
|
api_name = params.get("api_name") or name
|
||||||
|
key_env = (params.get("key_env") or "API_KEY").upper()
|
||||||
|
# safe: nur Buchstaben/Zahlen/Underscore
|
||||||
|
key_env = re.sub(r"[^A-Z0-9_]", "_", key_env)
|
||||||
|
auth_header = params.get("auth_header") or "Authorization"
|
||||||
|
auth_prefix = params.get("auth_prefix") if "auth_prefix" in params else "Bearer "
|
||||||
|
default_base_url = params.get("base_url") or "https://api.example.com"
|
||||||
|
tokens = {
|
||||||
|
"NAME": name,
|
||||||
|
"API_NAME": api_name,
|
||||||
|
"KEY_ENV": key_env,
|
||||||
|
"AUTH_HEADER": auth_header,
|
||||||
|
"AUTH_PREFIX": auth_prefix,
|
||||||
|
"DEFAULT_BASE_URL": default_base_url,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"entry_code": _replace_tokens(_APIKEY_API_CODE, tokens),
|
||||||
|
"readme": _replace_tokens(_APIKEY_API_README, tokens),
|
||||||
|
"pip_packages": [],
|
||||||
|
"args": [
|
||||||
|
{"name": "method", "type": "string", "required": False,
|
||||||
|
"description": "HTTP-Methode (Default GET)"},
|
||||||
|
{"name": "path", "type": "string", "required": True,
|
||||||
|
"description": "API-Pfad inkl. Query-String"},
|
||||||
|
{"name": "body", "type": "string", "required": False,
|
||||||
|
"description": "JSON-Body fuer POST/PUT"},
|
||||||
|
{"name": "base_url", "type": "string", "required": False,
|
||||||
|
"description": "Override der Base-URL"},
|
||||||
|
],
|
||||||
|
"config_schema": [
|
||||||
|
{"name": key_env, "type": "password", "label": f"{api_name} API-Key",
|
||||||
|
"secret": True, "description": f"Persoenlicher API-Key fuer {api_name}"},
|
||||||
|
],
|
||||||
|
"description": f"API-Wrapper fuer {api_name} (Key aus CFG_{key_env}).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Template 3: file-process ─────────────────────────────────────────
|
||||||
|
# Nimmt eine Datei aus /shared/uploads/, ruft eine User-Funktion drauf
|
||||||
|
# auf, schreibt das Resultat nach /shared/uploads/. Skelett — ARIA fuellt
|
||||||
|
# die `process()`-Funktion danach via skill_update mit dem echten Code.
|
||||||
|
|
||||||
|
_FILE_PROCESS_CODE = '''"""
|
||||||
|
{{NAME}} — File-Processing-Skelett.
|
||||||
|
|
||||||
|
Liest eine Eingabe-Datei aus /shared/uploads/, ruft process() auf,
|
||||||
|
schreibt Output zurueck nach /shared/uploads/.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ARG_INPUT = Pfad zur Eingabedatei (z.B. /shared/uploads/foo.pdf)
|
||||||
|
ARG_OUTPUT = Optional Pfad fuer Output (Default: <input>.{{OUTPUT_EXT}})
|
||||||
|
|
||||||
|
ARIA-Hinweis: die process()-Funktion ist ein Stub — passe sie via
|
||||||
|
skill_update an deine Aufgabe an. pip_packages bei Bedarf via
|
||||||
|
skill_update ergaenzen (z.B. pypdf, Pillow, reportlab).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def process(input_path: str, output_path: str) -> None:
|
||||||
|
"""Eigentlicher Verarbeitungs-Schritt. Hier kommt der Code rein."""
|
||||||
|
# STUB: kopiert die Datei einfach. ARIA: ueberschreibe diese Funktion.
|
||||||
|
shutil.copy(input_path, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
inp = (os.environ.get("ARG_INPUT") or "").strip()
|
||||||
|
if not inp:
|
||||||
|
print("FEHLER: ARG_INPUT erforderlich", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not os.path.exists(inp):
|
||||||
|
print(f"FEHLER: Eingabe nicht gefunden: {inp}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
out = (os.environ.get("ARG_OUTPUT") or "").strip()
|
||||||
|
if not out:
|
||||||
|
base, _ = os.path.splitext(inp)
|
||||||
|
out = f"{base}.{{OUTPUT_EXT}}"
|
||||||
|
try:
|
||||||
|
process(inp, out)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FEHLER bei process(): {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(out) # stdout = Pfad zur Ausgabe-Datei, ARIA kann den dem User zurueckgeben
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
'''
|
||||||
|
|
||||||
|
_FILE_PROCESS_README = '''# {{NAME}}
|
||||||
|
|
||||||
|
File-Processing-Skelett (`skill_scaffold(template="file-process")`).
|
||||||
|
|
||||||
|
Liest eine Datei aus `/shared/uploads/`, ruft die `process()`-Funktion auf,
|
||||||
|
schreibt das Resultat zurueck. Die `process()`-Funktion ist initial ein
|
||||||
|
Stub (kopiert nur) — ARIA passt sie via `skill_update` an die konkrete
|
||||||
|
Aufgabe an.
|
||||||
|
|
||||||
|
## Args
|
||||||
|
|
||||||
|
| Name | Default | Beschreibung |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| input | - | Eingabedatei (z.B. /shared/uploads/foo.pdf) |
|
||||||
|
| output | `<input>.{{OUTPUT_EXT}}` | Ausgabepfad (optional) |
|
||||||
|
|
||||||
|
stdout = Pfad zur erzeugten Datei → ARIA kann ihn dem User zurueckgeben.
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _file_process(name: str, params: dict) -> dict:
|
||||||
|
output_ext = (params.get("output_ext") or "out").strip().lstrip(".")
|
||||||
|
output_ext = re.sub(r"[^a-zA-Z0-9]", "", output_ext) or "out"
|
||||||
|
tokens = {
|
||||||
|
"NAME": name,
|
||||||
|
"OUTPUT_EXT": output_ext,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"entry_code": _replace_tokens(_FILE_PROCESS_CODE, tokens),
|
||||||
|
"readme": _replace_tokens(_FILE_PROCESS_README, tokens),
|
||||||
|
"pip_packages": [],
|
||||||
|
"args": [
|
||||||
|
{"name": "input", "type": "string", "required": True,
|
||||||
|
"description": "Eingabedatei (z.B. /shared/uploads/foo.pdf)"},
|
||||||
|
{"name": "output", "type": "string", "required": False,
|
||||||
|
"description": f"Output-Pfad (Default <input>.{output_ext})"},
|
||||||
|
],
|
||||||
|
"config_schema": [],
|
||||||
|
"description": f"File-Processing-Skelett (Input → process() → Output.{output_ext}).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Registry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEMPLATES: dict[str, Callable[[str, dict], dict]] = {
|
||||||
|
"oauth-api": _oauth_api,
|
||||||
|
"apikey-api": _apikey_api,
|
||||||
|
"file-process": _file_process,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_templates() -> list[dict]:
|
||||||
|
"""Liste aller verfuegbaren Templates mit Kurzbeschreibung — fuer UI/Tool-Doku."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "oauth-api",
|
||||||
|
"description": "OAuth2-API-Wrapper (Spotify, GitHub, Reddit, Google, …). "
|
||||||
|
"Token kommt vom Brain mit Auto-Refresh. Args: method/path/body.",
|
||||||
|
"params": ["service (str, OAuth-Service-Name)", "base_url (str, optional)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apikey-api",
|
||||||
|
"description": "API-Wrapper fuer Services mit statischem API-Key "
|
||||||
|
"(OpenWeather, OpenAI, Twilio, …). Key liegt im Skill-Config "
|
||||||
|
"und kommt als CFG_<NAME> ENV — kein hardcode.",
|
||||||
|
"params": ["api_name (str)", "key_env (str, ENV-Name fuer den Key)",
|
||||||
|
"auth_header (str, default 'Authorization')",
|
||||||
|
"auth_prefix (str, default 'Bearer ')",
|
||||||
|
"base_url (str)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "file-process",
|
||||||
|
"description": "Skelett fuer File-In/File-Out-Operationen "
|
||||||
|
"(PDF konvertieren, Bild bearbeiten, JSON umformen). "
|
||||||
|
"process()-Funktion ist Stub, ARIA fuellt sie via skill_update.",
|
||||||
|
"params": ["output_ext (str, Datei-Endung des Outputs)"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def expand(name: str, template: str, params: dict | None = None) -> dict:
|
||||||
|
"""Expandiert ein Template zu einem fertigen Skill-Spec.
|
||||||
|
|
||||||
|
Returns: dict mit entry_code / readme / pip_packages / args /
|
||||||
|
config_schema / description — direkt an create_skill weitergebbar.
|
||||||
|
|
||||||
|
Wirft ValueError wenn das Template nicht existiert.
|
||||||
|
"""
|
||||||
|
fn = TEMPLATES.get(template)
|
||||||
|
if not fn:
|
||||||
|
raise ValueError(
|
||||||
|
f"Template '{template}' unbekannt. Verfuegbar: {sorted(TEMPLATES.keys())}"
|
||||||
|
)
|
||||||
|
return fn(name, params or {})
|
||||||
@@ -47,9 +47,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
|
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
|
||||||
SHARED_UPLOADS = Path("/shared/uploads")
|
SHARED_UPLOADS = Path("/shared/uploads")
|
||||||
|
SKILL_CONFIGS_FILE = Path(os.environ.get("SKILL_CONFIGS_FILE", "/shared/config/skill_configs.json"))
|
||||||
|
|
||||||
|
# Beim Archivieren in versions/ ausgenommen (gross, regenerierbar, sind keine Sources)
|
||||||
|
_VERSION_SKIP = {"venv", "logs", "versions", "__pycache__"}
|
||||||
|
|
||||||
VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"}
|
VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"}
|
||||||
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
|
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
|
||||||
|
# Anti-Skill-Friedhof: ARIAs Lieblings-Suffixe wenn sie statt updaten neu baut
|
||||||
|
VERSION_SUFFIX_RE = re.compile(r"(?:[-_]v\d+|[-_](?:new|fixed|old|alt|copy|final|clean))$", re.I)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
@@ -66,6 +72,44 @@ def _skill_dir(name: str) -> Path:
|
|||||||
return SKILLS_DIR / _safe_name(name)
|
return SKILLS_DIR / _safe_name(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_anti_graveyard(name: str) -> None:
|
||||||
|
"""Verhindert klassische Skill-Friedhof-Patterns beim Anlegen.
|
||||||
|
|
||||||
|
Hard-Reject auf:
|
||||||
|
1. Versions-Suffixe (`-v2`, `_v3`, `-new`, `-fixed`, …) im Namen
|
||||||
|
2. Prefix-Kollision mit existierendem Skill (z.B. `spotify` existiert,
|
||||||
|
jemand will `spotify-aria` oder `spotify-ctl` anlegen)
|
||||||
|
"""
|
||||||
|
if VERSION_SUFFIX_RE.search(name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Skill-Name '{name}' enthaelt einen Versions-Suffix "
|
||||||
|
f"(-v2 / _v3 / -new / -fixed / -old / -alt / -copy / -final / -clean). "
|
||||||
|
f"Skills werden intern versioniert (skill_rollback). "
|
||||||
|
f"Waehle einen klaren Namen ohne Suffix oder nutze skill_update auf "
|
||||||
|
f"den bestehenden Skill."
|
||||||
|
)
|
||||||
|
if not SKILLS_DIR.exists():
|
||||||
|
return
|
||||||
|
existing = [p.name for p in SKILLS_DIR.iterdir() if p.is_dir()]
|
||||||
|
for ex in existing:
|
||||||
|
if ex == name:
|
||||||
|
continue # wird spaeter mit "existiert bereits" abgefangen
|
||||||
|
# neuer Name verlaengert existierenden Stem: 'spotify' da, neu 'spotify-aria'
|
||||||
|
if name.startswith(ex + "-") or name.startswith(ex + "_"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Skill-Name '{name}' kollidiert mit existierendem '{ex}'. "
|
||||||
|
f"Wenn Du '{ex}' verbessern willst: skill_update auf '{ex}'. "
|
||||||
|
f"Wenn es wirklich was anderes ist: waehle einen Namen ohne den "
|
||||||
|
f"Praefix '{ex}-' / '{ex}_'."
|
||||||
|
)
|
||||||
|
# neuer Name ist Kurzform eines existierenden: 'spotify-aria' da, neu 'spotify'
|
||||||
|
if ex.startswith(name + "-") or ex.startswith(name + "_"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Es existiert bereits '{ex}' mit Praefix '{name}'. Pruefe ob '{ex}' "
|
||||||
|
f"das schon kann; wenn ja: skill_update auf '{ex}' oder Skill umbenennen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── Listing ────────────────────────────────────────────────────────
|
# ─── Listing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def list_skills(active_only: bool = False) -> list[dict]:
|
def list_skills(active_only: bool = False) -> list[dict]:
|
||||||
@@ -119,6 +163,7 @@ def create_skill(
|
|||||||
requires: Optional[dict] = None,
|
requires: Optional[dict] = None,
|
||||||
pip_packages: Optional[list[str]] = None,
|
pip_packages: Optional[list[str]] = None,
|
||||||
author: str = "aria",
|
author: str = "aria",
|
||||||
|
config_schema: Optional[list] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||||
|
|
||||||
@@ -128,6 +173,7 @@ def create_skill(
|
|||||||
name = _safe_name(name)
|
name = _safe_name(name)
|
||||||
if execution not in VALID_EXECUTIONS:
|
if execution not in VALID_EXECUTIONS:
|
||||||
raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein")
|
raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein")
|
||||||
|
_check_anti_graveyard(name)
|
||||||
d = _skill_dir(name)
|
d = _skill_dir(name)
|
||||||
if d.exists():
|
if d.exists():
|
||||||
raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten")
|
raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten")
|
||||||
@@ -166,6 +212,8 @@ def create_skill(
|
|||||||
"use_count": 0,
|
"use_count": 0,
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"author": author,
|
"author": author,
|
||||||
|
"config_schema": _normalize_config_schema(config_schema),
|
||||||
|
"version_history": [],
|
||||||
}
|
}
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
|
|
||||||
@@ -184,6 +232,35 @@ def create_skill(
|
|||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_config_schema(schema: Optional[list]) -> list:
|
||||||
|
"""Filter + Normalisiert das config_schema. Erwartet Liste von Dicts mit
|
||||||
|
Pflichtfeld 'name'. Optional: label, type (string|number|boolean|password),
|
||||||
|
secret (bool), default, description."""
|
||||||
|
if not schema:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for f in schema:
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
fname = (f.get("name") or "").strip()
|
||||||
|
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]{0,40}$", fname):
|
||||||
|
continue
|
||||||
|
ftype = (f.get("type") or "string").lower()
|
||||||
|
if ftype not in ("string", "number", "boolean", "password"):
|
||||||
|
ftype = "string"
|
||||||
|
# password impliziert secret=True
|
||||||
|
secret = bool(f.get("secret")) or ftype == "password"
|
||||||
|
out.append({
|
||||||
|
"name": fname,
|
||||||
|
"type": ftype,
|
||||||
|
"label": (f.get("label") or fname),
|
||||||
|
"secret": secret,
|
||||||
|
"description": (f.get("description") or "")[:300],
|
||||||
|
"default": f.get("default"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
||||||
venv = skill_dir / "venv"
|
venv = skill_dir / "venv"
|
||||||
logger.info("venv erstellen: %s", venv)
|
logger.info("venv erstellen: %s", venv)
|
||||||
@@ -194,25 +271,344 @@ def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def update_skill(name: str, patch: dict) -> dict:
|
def update_skill(name: str, patch: dict) -> dict:
|
||||||
|
"""Aktualisiert einen bestehenden Skill. Manifest-Felder ueber den
|
||||||
|
`allowed`-Filter, Code-Aenderungen ueber dedizierte Keys:
|
||||||
|
|
||||||
|
- `entry_code` (str) → ueberschreibt run.py / run.sh
|
||||||
|
- `readme` (str) → ueberschreibt README.md
|
||||||
|
- `pip_packages` (list) → ueberschreibt requirements.txt + venv-Rebuild
|
||||||
|
(nur bei local-venv)
|
||||||
|
"""
|
||||||
manifest = read_manifest(name)
|
manifest = read_manifest(name)
|
||||||
if manifest is None:
|
if manifest is None:
|
||||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||||
|
d = _skill_dir(name)
|
||||||
|
|
||||||
|
# Auto-Archive: wenn strukturelle Aenderung (Code/README/Deps/Schema), erst
|
||||||
|
# snapshot machen. So kann jeder skill_update zurueckgerollt werden.
|
||||||
|
structural = any(k in patch for k in ("entry_code", "readme", "pip_packages",
|
||||||
|
"config_schema", "args"))
|
||||||
|
if structural:
|
||||||
|
try:
|
||||||
|
archive_current_version(
|
||||||
|
name,
|
||||||
|
summary=patch.get("_change_summary") or ", ".join(
|
||||||
|
sorted(k for k in patch.keys() if k != "_change_summary")
|
||||||
|
)[:200],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("update_skill: Auto-Archive %s fehlgeschlagen: %s", name, exc)
|
||||||
|
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||||
|
manifest = read_manifest(name) or manifest
|
||||||
|
|
||||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
||||||
for k, v in patch.items():
|
for k, v in patch.items():
|
||||||
if k in allowed:
|
if k in allowed:
|
||||||
manifest[k] = v
|
manifest[k] = v
|
||||||
|
if "config_schema" in patch:
|
||||||
|
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||||
|
|
||||||
|
# Code austauschen
|
||||||
|
if "entry_code" in patch and patch["entry_code"]:
|
||||||
|
execution = manifest.get("execution", "local-venv")
|
||||||
|
if execution == "local-venv":
|
||||||
|
entry_path = d / "run.py"
|
||||||
|
entry_path.write_text(patch["entry_code"], encoding="utf-8")
|
||||||
|
else:
|
||||||
|
entry_path = d / "run.sh"
|
||||||
|
content = patch["entry_code"] if patch["entry_code"].startswith("#!") else "#!/usr/bin/env bash\nset -euo pipefail\n" + patch["entry_code"]
|
||||||
|
entry_path.write_text(content, encoding="utf-8")
|
||||||
|
entry_path.chmod(0o755)
|
||||||
|
|
||||||
|
# README austauschen
|
||||||
|
if "readme" in patch and patch["readme"] is not None:
|
||||||
|
(d / "README.md").write_text(patch["readme"], encoding="utf-8")
|
||||||
|
|
||||||
|
# pip_packages geaendert → requirements.txt + venv neu aufbauen
|
||||||
|
if "pip_packages" in patch and manifest.get("execution") == "local-venv":
|
||||||
|
pip_packages = patch["pip_packages"] or []
|
||||||
|
(d / "requirements.txt").write_text("\n".join(pip_packages) + "\n", encoding="utf-8")
|
||||||
|
# venv loeschen + neu aufbauen, damit alte Pakete weg sind
|
||||||
|
venv = d / "venv"
|
||||||
|
if venv.exists():
|
||||||
|
shutil.rmtree(venv, ignore_errors=True)
|
||||||
|
try:
|
||||||
|
_setup_venv(d, pip_packages)
|
||||||
|
# Falls vorher wegen Setup-Error deaktiviert war: jetzt frei
|
||||||
|
manifest.pop("setup_error", None)
|
||||||
|
manifest["active"] = patch.get("active", True)
|
||||||
|
except Exception as exc:
|
||||||
|
manifest["active"] = False
|
||||||
|
manifest["setup_error"] = str(exc)[:500]
|
||||||
|
logger.warning("Skill %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
|
||||||
|
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
|
logger.info("Skill aktualisiert: %s (keys=%s)", name, sorted(patch.keys()))
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold_skill(
|
||||||
|
name: str,
|
||||||
|
template: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
author: str = "aria",
|
||||||
|
) -> dict:
|
||||||
|
"""Baut einen Skill aus einem Template-Skelett. ARIA muss nicht jedes Mal
|
||||||
|
einen kompletten Python-Skill schreiben — sie waehlt ein Template und
|
||||||
|
optionale Parameter, Brain expandiert das zu fertigem Code.
|
||||||
|
|
||||||
|
Templates siehe `skill_templates.TEMPLATES`. Konkret:
|
||||||
|
- 'oauth-api' : params={service, base_url?}
|
||||||
|
- 'apikey-api': params={api_name, key_env, auth_header?, auth_prefix?, base_url?}
|
||||||
|
- 'file-process': params={output_ext?}
|
||||||
|
|
||||||
|
Wirft ValueError wenn Template unbekannt oder Name kollidiert.
|
||||||
|
Sonst: ruft intern create_skill mit den expandierten Feldern auf.
|
||||||
|
"""
|
||||||
|
import skill_templates as _st
|
||||||
|
spec = _st.expand(name, template, params or {})
|
||||||
|
return create_skill(
|
||||||
|
name=name,
|
||||||
|
description=spec["description"],
|
||||||
|
execution="local-venv",
|
||||||
|
entry_code=spec["entry_code"],
|
||||||
|
readme=spec["readme"],
|
||||||
|
args=spec["args"],
|
||||||
|
pip_packages=spec["pip_packages"],
|
||||||
|
config_schema=spec["config_schema"],
|
||||||
|
author=author,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def delete_skill(name: str) -> None:
|
def delete_skill(name: str) -> None:
|
||||||
d = _skill_dir(name)
|
d = _skill_dir(name)
|
||||||
if not d.exists():
|
if not d.exists():
|
||||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||||
shutil.rmtree(d)
|
shutil.rmtree(d)
|
||||||
|
# Configs auch raeumen — sonst Karteileiche in skill_configs.json
|
||||||
|
try:
|
||||||
|
all_cfg = _load_all_skill_configs()
|
||||||
|
if name in all_cfg:
|
||||||
|
all_cfg.pop(name)
|
||||||
|
_save_all_skill_configs(all_cfg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Skill geloescht: %s", name)
|
logger.info("Skill geloescht: %s", name)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Skill-Configs (statische Werte je Skill — API-Keys, IDs etc.) ──
|
||||||
|
# Werte liegen zentral in /shared/config/skill_configs.json damit Stefan
|
||||||
|
# sie im Diagnostic-UI editieren kann. Skill bekommt sie zur Laufzeit
|
||||||
|
# als ENV `CFG_<UPPER_NAME>` — kein hardcoden im Code noetig.
|
||||||
|
|
||||||
|
def _load_all_skill_configs() -> dict:
|
||||||
|
if not SKILL_CONFIGS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(SKILL_CONFIGS_FILE.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("skill_configs.json kaputt (%s) — leeres dict", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all_skill_configs(data: dict) -> None:
|
||||||
|
SKILL_CONFIGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
SKILL_CONFIGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_config(name: str) -> dict:
|
||||||
|
"""Liefert die rohen Config-Werte fuer einen Skill (ungemasked).
|
||||||
|
Wird intern beim run_skill genutzt um CFG_<NAME>-Env zu bauen."""
|
||||||
|
return _load_all_skill_configs().get(name, {})
|
||||||
|
|
||||||
|
|
||||||
|
def set_skill_config(name: str, values: dict) -> dict:
|
||||||
|
"""Speichert die Config-Werte fuer einen Skill (komplett ueberschreiben).
|
||||||
|
Werte landen sofort persistent; naechster run_skill nutzt sie."""
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
raise ValueError("values muss ein Dict sein")
|
||||||
|
all_cfg = _load_all_skill_configs()
|
||||||
|
all_cfg[name] = values
|
||||||
|
_save_all_skill_configs(all_cfg)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_config_masked(name: str) -> dict:
|
||||||
|
"""Wie get_skill_config, aber secret-Felder werden auf '***SET***' maskiert.
|
||||||
|
Schema kommt aus dem skill.json — Felder ohne secret=True werden klar
|
||||||
|
zurueckgegeben. Fuer UI-Anzeige."""
|
||||||
|
manifest = read_manifest(name)
|
||||||
|
schema = (manifest or {}).get("config_schema") or []
|
||||||
|
secret_fields = {f.get("name") for f in schema if f.get("secret")}
|
||||||
|
values = get_skill_config(name)
|
||||||
|
return {k: ("***SET***" if (k in secret_fields and v) else v)
|
||||||
|
for k, v in values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _config_env_name(field_name: str) -> str:
|
||||||
|
"""API-Key → CFG_API_KEY. Erlaubt nur a-zA-Z0-9_."""
|
||||||
|
safe = re.sub(r"[^a-zA-Z0-9]", "_", field_name).upper()
|
||||||
|
return f"CFG_{safe}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Versionierung (Rollback-fähiges update_skill) ───────────────────
|
||||||
|
# Vor jedem strukturellen update wird der aktuelle Stand nach
|
||||||
|
# versions/v_<ts>/ kopiert (ohne venv/logs/versions). Rollback kopiert
|
||||||
|
# eine Version zurueck — vorher noch ein Auto-Snapshot, damit auch der
|
||||||
|
# Rollback rueckholbar ist.
|
||||||
|
|
||||||
|
def _versions_dir(name: str) -> Path:
|
||||||
|
return _skill_dir(name) / "versions"
|
||||||
|
|
||||||
|
|
||||||
|
def _copytree_skill(src: Path, dst: Path) -> None:
|
||||||
|
"""Kopiert Skill-Sources (alles ausser venv/logs/versions/__pycache__)."""
|
||||||
|
dst.mkdir(parents=True, exist_ok=True)
|
||||||
|
for item in src.iterdir():
|
||||||
|
if item.name in _VERSION_SKIP:
|
||||||
|
continue
|
||||||
|
target = dst / item.name
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.copytree(item, target, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, target)
|
||||||
|
|
||||||
|
|
||||||
|
def archive_current_version(name: str, summary: str = "") -> str:
|
||||||
|
"""Kopiert den aktuellen Skill-Stand nach versions/v_<ts>/. Returnt die
|
||||||
|
version_id. Im Manifest wird `version_history` gepflegt."""
|
||||||
|
d = _skill_dir(name)
|
||||||
|
if not d.exists():
|
||||||
|
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||||
|
ts = int(time.time())
|
||||||
|
version_id = f"v_{ts}"
|
||||||
|
# Kollisionsschutz bei sub-Sekunden-Calls
|
||||||
|
while (_versions_dir(name) / version_id).exists():
|
||||||
|
ts += 1
|
||||||
|
version_id = f"v_{ts}"
|
||||||
|
archive = _versions_dir(name) / version_id
|
||||||
|
_copytree_skill(d, archive)
|
||||||
|
(archive / "_version.json").write_text(json.dumps({
|
||||||
|
"version_id": version_id,
|
||||||
|
"archived_at": _now(),
|
||||||
|
"summary": (summary or "")[:300],
|
||||||
|
}, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
# Manifest-History pflegen (read-back nach _copytree, damit history konsistent)
|
||||||
|
manifest = read_manifest(name)
|
||||||
|
if manifest is not None:
|
||||||
|
hist = list(manifest.get("version_history") or [])
|
||||||
|
hist.append({"version_id": version_id, "archived_at": _now(),
|
||||||
|
"summary": (summary or "")[:300]})
|
||||||
|
# Cap auf 50 Versionen — alte Eintraege wegrotieren (Dateien bleiben aber)
|
||||||
|
manifest["version_history"] = hist[-50:]
|
||||||
|
write_manifest(name, manifest)
|
||||||
|
return version_id
|
||||||
|
|
||||||
|
|
||||||
|
def list_skill_versions(name: str) -> list[dict]:
|
||||||
|
"""Liste aller archivierten Versionen, neueste zuerst."""
|
||||||
|
versions = _versions_dir(name)
|
||||||
|
if not versions.exists():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for entry in sorted(versions.iterdir(), reverse=True):
|
||||||
|
if not entry.is_dir():
|
||||||
|
continue
|
||||||
|
meta = entry / "_version.json"
|
||||||
|
if meta.exists():
|
||||||
|
try:
|
||||||
|
out.append(json.loads(meta.read_text(encoding="utf-8")))
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out.append({"version_id": entry.name, "archived_at": "", "summary": ""})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_skill(name: str, version_id: str) -> dict:
|
||||||
|
"""Stellt eine archivierte Version wieder her. Vorher wird der aktuelle
|
||||||
|
Stand automatisch als neue Version archiviert ('safety_snapshot') —
|
||||||
|
Rollback ist also nicht destruktiv. venv wird neu aufgebaut wenn
|
||||||
|
requirements.txt vorhanden ist."""
|
||||||
|
d = _skill_dir(name)
|
||||||
|
if not d.exists():
|
||||||
|
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||||
|
archive = _versions_dir(name) / version_id
|
||||||
|
if not archive.exists() or not archive.is_dir():
|
||||||
|
raise ValueError(f"Version '{version_id}' fuer Skill '{name}' nicht gefunden")
|
||||||
|
|
||||||
|
# 1. Sicherung des aktuellen Stands
|
||||||
|
safety = archive_current_version(name, summary=f"safety-snapshot vor rollback auf {version_id}")
|
||||||
|
|
||||||
|
# 2. Aktuelle Sources loeschen (venv/logs/versions bleiben)
|
||||||
|
for item in d.iterdir():
|
||||||
|
if item.name in _VERSION_SKIP:
|
||||||
|
continue
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.rmtree(item, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
item.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Archive zurueck kopieren (ohne _version.json — das ist Versions-Metadata)
|
||||||
|
for item in archive.iterdir():
|
||||||
|
if item.name == "_version.json":
|
||||||
|
continue
|
||||||
|
target = d / item.name
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.copytree(item, target, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(item, target)
|
||||||
|
|
||||||
|
# 4. Manifest-Stempel
|
||||||
|
manifest = read_manifest(name)
|
||||||
|
if manifest is not None:
|
||||||
|
manifest["updated_at"] = _now()
|
||||||
|
manifest["last_rollback"] = {"to": version_id, "safety": safety, "at": _now()}
|
||||||
|
write_manifest(name, manifest)
|
||||||
|
|
||||||
|
# 5. venv-Rebuild bei local-venv
|
||||||
|
req_file = d / "requirements.txt"
|
||||||
|
if (manifest or {}).get("execution") == "local-venv" and req_file.exists():
|
||||||
|
pip_packages = [l.strip() for l in req_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
if l.strip() and not l.strip().startswith("#")]
|
||||||
|
venv = d / "venv"
|
||||||
|
if venv.exists():
|
||||||
|
shutil.rmtree(venv, ignore_errors=True)
|
||||||
|
try:
|
||||||
|
_setup_venv(d, pip_packages)
|
||||||
|
if manifest is not None:
|
||||||
|
manifest.pop("setup_error", None)
|
||||||
|
manifest["active"] = True
|
||||||
|
write_manifest(name, manifest)
|
||||||
|
except Exception as exc:
|
||||||
|
if manifest is not None:
|
||||||
|
manifest["active"] = False
|
||||||
|
manifest["setup_error"] = str(exc)[:500]
|
||||||
|
write_manifest(name, manifest)
|
||||||
|
logger.warning("Rollback %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
|
||||||
|
|
||||||
|
return {"ok": True, "name": name, "rolled_back_to": version_id,
|
||||||
|
"safety_snapshot": safety}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_skill_version(name: str, version_id: str) -> dict:
|
||||||
|
"""Loescht eine einzelne Version aus versions/. Nicht-rueckholbar."""
|
||||||
|
archive = _versions_dir(name) / version_id
|
||||||
|
if not archive.exists():
|
||||||
|
raise ValueError(f"Version '{version_id}' nicht gefunden")
|
||||||
|
shutil.rmtree(archive)
|
||||||
|
manifest = read_manifest(name)
|
||||||
|
if manifest is not None:
|
||||||
|
manifest["version_history"] = [v for v in (manifest.get("version_history") or [])
|
||||||
|
if v.get("version_id") != version_id]
|
||||||
|
write_manifest(name, manifest)
|
||||||
|
return {"ok": True, "deleted": version_id}
|
||||||
|
|
||||||
|
|
||||||
# ─── Run ────────────────────────────────────────────────────────────
|
# ─── Run ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict:
|
def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict:
|
||||||
@@ -239,6 +635,22 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
|||||||
env[f"ARG_{k.upper()}"] = str(v)
|
env[f"ARG_{k.upper()}"] = str(v)
|
||||||
env["SKILL_DIR"] = str(d)
|
env["SKILL_DIR"] = str(d)
|
||||||
env["SHARED_UPLOADS"] = str(SHARED_UPLOADS)
|
env["SHARED_UPLOADS"] = str(SHARED_UPLOADS)
|
||||||
|
# Brain-API fuer Skills die OAuth-Tokens / Brain-Helpers brauchen.
|
||||||
|
# Beispiel: requests.get(f"{os.environ['BRAIN_INTERNAL_URL']}/oauth/spotify/token")
|
||||||
|
env["BRAIN_INTERNAL_URL"] = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
|
||||||
|
# Config-Schema-Werte als CFG_<NAME>-ENV (P3). Default greift wenn Stefan
|
||||||
|
# noch keinen Wert gesetzt hat — None wird uebersprungen damit der Skill
|
||||||
|
# selbst entscheiden kann ob das ein Fehler ist.
|
||||||
|
schema = manifest.get("config_schema") or []
|
||||||
|
values = get_skill_config(name)
|
||||||
|
for field in schema:
|
||||||
|
fname = field.get("name")
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
val = values.get(fname, field.get("default"))
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
env[_config_env_name(fname)] = str(val)
|
||||||
|
|
||||||
# Command bauen
|
# Command bauen
|
||||||
if exec_mode == "local-venv":
|
if exec_mode == "local-venv":
|
||||||
|
|||||||
+81
-7
@@ -25,7 +25,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
|
|||||||
|
|
||||||
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
|
|
||||||
|
# Maximales GPS-Alter fuer near()-Auswertung. Wenn die App laenger nicht
|
||||||
|
# gepushed hat (z.B. Tracking aus, Mobilfunk weg, App geschlossen), gilt
|
||||||
|
# die Position als "unbekannt" und near() liefert False — verhindert
|
||||||
|
# Phantom-Fires basierend auf einer wochen-alten Position.
|
||||||
|
NEAR_MAX_AGE_SEC = 5 * 60
|
||||||
|
|
||||||
|
|
||||||
def _gps_state() -> dict[str, Any]:
|
def _gps_state() -> dict[str, Any]:
|
||||||
"""Letzte bekannte Position aus /shared/state/location.json.
|
"""Letzte bekannte Position aus /shared/state/location.json.
|
||||||
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
|
|||||||
return int(time.time() - ts)
|
return int(time.time() - ts)
|
||||||
|
|
||||||
|
|
||||||
def collect_variables() -> dict[str, Any]:
|
def _near_key(lat: float, lon: float, radius_m: float) -> str:
|
||||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
|
"""Stabiler Schluessel pro near()-Aufruf — fuer entered_near/left_near
|
||||||
|
State-Tracking pro Trigger pro Aufrufstelle."""
|
||||||
|
return f"{float(lat):.6f},{float(lon):.6f},{int(float(radius_m))}"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_variables(prev_near_states: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
|
||||||
|
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.
|
||||||
|
|
||||||
|
prev_near_states: pro Trigger gespeicherter Zustand vom letzten Eval
|
||||||
|
(für entered_near/left_near). Wird vom background-Loop reingegeben.
|
||||||
|
Nach dem Eval kann man `vars_['_new_near_states']` auslesen, um den
|
||||||
|
Update-Snapshot zurueck ins Trigger-Manifest zu schreiben."""
|
||||||
|
if prev_near_states is None:
|
||||||
|
prev_near_states = {}
|
||||||
|
new_near_states: Dict[str, bool] = {}
|
||||||
free_gb, free_pct = _disk_stats()
|
free_gb, free_pct = _disk_stats()
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
gps = _gps_state()
|
gps = _gps_state()
|
||||||
@@ -176,12 +196,17 @@ def collect_variables() -> dict[str, Any]:
|
|||||||
|
|
||||||
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
|
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
|
||||||
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
||||||
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt."""
|
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.
|
||||||
|
Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als
|
||||||
|
veraltet betrachtet → False."""
|
||||||
cur_lat = vars_.get("current_lat")
|
cur_lat = vars_.get("current_lat")
|
||||||
cur_lon = vars_.get("current_lon")
|
cur_lon = vars_.get("current_lon")
|
||||||
if cur_lat is None or cur_lon is None:
|
if cur_lat is None or cur_lon is None:
|
||||||
return False
|
return False
|
||||||
|
age = vars_.get("location_age_sec")
|
||||||
|
if isinstance(age, (int, float)) and age >= 0 and age > NEAR_MAX_AGE_SEC:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
R = 6371000.0
|
R = 6371000.0
|
||||||
phi1 = math.radians(float(cur_lat))
|
phi1 = math.radians(float(cur_lat))
|
||||||
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True solange im Radius drin. Plus State-Tracking fuer
|
||||||
|
entered_near/left_near — wir merken uns das letzte Ergebnis
|
||||||
|
damit Uebergaenge erkannt werden koennen."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
new_near_states[_near_key(lat, lon, radius_m)] = current
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _entered_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True NUR beim Uebergang draussen → innen. Use-Case: einmal
|
||||||
|
feuern wenn der User in den Radius reinfaehrt (Blitzer-Warner,
|
||||||
|
Ankunft-Erinnerung). Bei groesserem Radius = Vorwarnung."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
key = _near_key(lat, lon, radius_m)
|
||||||
|
new_near_states[key] = current
|
||||||
|
prev = bool(prev_near_states.get(key, False))
|
||||||
|
return current and not prev
|
||||||
|
|
||||||
|
def _left_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True NUR beim Uebergang innen → draussen. Use-Case: 'Hast
|
||||||
|
du am Parkplatz X was vergessen?' beim Verlassen."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
key = _near_key(lat, lon, radius_m)
|
||||||
|
new_near_states[key] = current
|
||||||
|
prev = bool(prev_near_states.get(key, False))
|
||||||
|
return prev and not current
|
||||||
|
|
||||||
vars_["near"] = _near
|
vars_["near"] = _near
|
||||||
|
vars_["entered_near"] = _entered_near
|
||||||
|
vars_["left_near"] = _left_near
|
||||||
|
# Update-Snapshot fuer den Caller (background-Loop schreibt das pro
|
||||||
|
# Trigger zurueck damit beim naechsten Tick prev_near_states stimmt)
|
||||||
|
vars_["_new_near_states"] = new_near_states
|
||||||
return vars_
|
return vars_
|
||||||
|
|
||||||
|
|
||||||
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
|
|||||||
{
|
{
|
||||||
"name": "near",
|
"name": "near",
|
||||||
"signature": "near(lat, lon, radius_m)",
|
"signature": "near(lat, lon, radius_m)",
|
||||||
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern "
|
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
|
||||||
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.",
|
"Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
|
||||||
|
"Use-Case: 'bin noch in der Naehe von X?'. "
|
||||||
|
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "entered_near",
|
||||||
|
"signature": "entered_near(lat, lon, radius_m)",
|
||||||
|
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
|
||||||
|
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
|
||||||
|
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
|
||||||
|
"Vorwarnung bevor man am Punkt ist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "left_near",
|
||||||
|
"signature": "left_near(lat, lon, radius_m)",
|
||||||
|
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
|
||||||
|
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
|
||||||
|
"vergessen?' beim Wegfahren.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+795
-28
File diff suppressed because it is too large
Load Diff
+1057
-143
File diff suppressed because it is too large
Load Diff
+169
-18
@@ -29,6 +29,40 @@ const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true";
|
|||||||
const RVS_TOKEN = process.env.RVS_TOKEN || "";
|
const RVS_TOKEN = process.env.RVS_TOKEN || "";
|
||||||
const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456";
|
const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456";
|
||||||
|
|
||||||
|
// ── Persistenz fuer agent_stream-Events ──────────────────
|
||||||
|
// Jeder agent_stream-Event wird parallel zum Broadcast in eine .jsonl
|
||||||
|
// geschrieben. Live-View laedt beim Tab-Oeffnen die letzten ~200 Zeilen,
|
||||||
|
// damit Browser-Reload / Standby den Verlauf nicht wegwerfen. Rotation
|
||||||
|
// haendelt logrotate / manual cleanup — wir cappen hier nur weichweich.
|
||||||
|
const AGENT_STREAM_LOG = process.env.AGENT_STREAM_LOG || "/shared/logs/agent_stream.jsonl";
|
||||||
|
const AGENT_STREAM_MAX_BYTES = 50 * 1024 * 1024; // 50 MB → halten den File handlebar
|
||||||
|
function appendAgentStream(payload) {
|
||||||
|
if (!payload || typeof payload !== "object") return;
|
||||||
|
try {
|
||||||
|
const line = JSON.stringify({ ts: Date.now(), ...payload }) + "\n";
|
||||||
|
// Soft-Cap: bei >50 MB ein Truncate auf den letzten ~25 MB Inhalt
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(AGENT_STREAM_LOG);
|
||||||
|
if (st.size > AGENT_STREAM_MAX_BYTES) {
|
||||||
|
const half = Math.floor(AGENT_STREAM_MAX_BYTES / 2);
|
||||||
|
const fd = fs.openSync(AGENT_STREAM_LOG, "r");
|
||||||
|
const buf = Buffer.alloc(half);
|
||||||
|
fs.readSync(fd, buf, 0, half, st.size - half);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
// bis zum naechsten Newline springen damit wir keine halbe Zeile haben
|
||||||
|
const firstNl = buf.indexOf(0x0a);
|
||||||
|
const start = firstNl >= 0 ? firstNl + 1 : 0;
|
||||||
|
fs.writeFileSync(AGENT_STREAM_LOG, buf.slice(start));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Verzeichnis sicherstellen
|
||||||
|
try { fs.mkdirSync(path.dirname(AGENT_STREAM_LOG), { recursive: true }); } catch {}
|
||||||
|
fs.appendFileSync(AGENT_STREAM_LOG, line);
|
||||||
|
} catch (e) {
|
||||||
|
// Schweigend ignorieren — Persistence darf den Stream nicht blockieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────
|
||||||
const state = {
|
const state = {
|
||||||
gateway: { status: "disconnected", lastError: null, handshakeOk: false },
|
gateway: { status: "disconnected", lastError: null, handshakeOk: false },
|
||||||
@@ -492,9 +526,10 @@ function handleGatewayMessage(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendToGateway(text, isTrace) {
|
function sendToGateway(text, isTrace) {
|
||||||
|
// OpenClaw-Gateway ist raus — Brain via Bridge via RVS ist die einzige
|
||||||
|
// Route. Wir loggen nichts mehr; alte Trace-Aufrufe schliessen wir clean.
|
||||||
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
|
if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) {
|
||||||
log("error", "gateway", "Nicht verbunden — kann nicht senden");
|
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
|
||||||
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +667,14 @@ function connectRVS(forcePlain) {
|
|||||||
tool: msg.payload?.tool || msg.tool || "",
|
tool: msg.payload?.tool || msg.tool || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (msg.type === "agent_stream") {
|
||||||
|
// Voller Live-Stream der Claude-Code-Session (assistant_text +
|
||||||
|
// tool_use mit Input + tool_result mit truncated Output). Geht
|
||||||
|
// 1:1 an Browser durch — die ARIA-Live-View rendert's.
|
||||||
|
// Zusaetzlich persistieren damit Browser-Reload / Standby den
|
||||||
|
// History-Verlauf nicht wegwirft.
|
||||||
|
try { appendAgentStream(msg.payload); } catch {}
|
||||||
|
broadcast({ type: "agent_stream", payload: msg.payload });
|
||||||
} else if (msg.type === "memory_saved") {
|
} else if (msg.type === "memory_saved") {
|
||||||
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
||||||
const m = msg.payload || {};
|
const m = msg.payload || {};
|
||||||
@@ -695,8 +738,16 @@ function connectRVS(forcePlain) {
|
|||||||
state.rvs.lastError = err.message;
|
state.rvs.lastError = err.message;
|
||||||
broadcastState();
|
broadcastState();
|
||||||
|
|
||||||
// TLS Fallback
|
// TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
|
||||||
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) {
|
// Bei Netz-Problemen wie EHOSTUNREACH, ECONNREFUSED, ENETUNREACH,
|
||||||
|
// EAI_AGAIN ist der Server eh tot — Fallback bringt nichts ausser
|
||||||
|
// Log-Spam und doppelten Retries.
|
||||||
|
const netErr = (err.code || err.message || "").toString();
|
||||||
|
const isNetDown =
|
||||||
|
/^(EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/.test(netErr) ||
|
||||||
|
/EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/.test(err.message || "");
|
||||||
|
|
||||||
|
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered && !isNetDown) {
|
||||||
fallbackTriggered = true;
|
fallbackTriggered = true;
|
||||||
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
|
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
|
||||||
try { ws.removeAllListeners(); ws.close(); } catch (_) {}
|
try { ws.removeAllListeners(); ws.close(); } catch (_) {}
|
||||||
@@ -757,22 +808,20 @@ function sendToRVS_raw(msgObj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendToRVS(text, isTrace) {
|
function sendToRVS(text, isTrace) {
|
||||||
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
|
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
||||||
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
|
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
||||||
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
|
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
||||||
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
|
// Diagnostic die Bubble live spiegeln koennen.
|
||||||
|
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
||||||
// 1. An Gateway senden (damit ARIA antwortet)
|
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
||||||
const gatewayOk = sendToGateway(text, isTrace);
|
return false;
|
||||||
|
}
|
||||||
// 2. An RVS senden (damit die App die Nachricht sieht)
|
|
||||||
sendToRVS_raw({
|
sendToRVS_raw({
|
||||||
type: "chat",
|
type: "chat",
|
||||||
payload: { text, sender: "diagnostic" },
|
payload: { text, sender: "diagnostic" },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
return gatewayOk;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Claude Proxy Test ────────────────────────────────────
|
// ── Claude Proxy Test ────────────────────────────────────
|
||||||
@@ -1457,7 +1506,12 @@ const server = http.createServer((req, res) => {
|
|||||||
log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
|
log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.on("close", () => { if (!zip.killed) zip.kill("SIGTERM"); });
|
// SIGTERM an zip nur wenn der Client wirklich disconnected
|
||||||
|
// (res.close vor res.end). req.on("close") feuert auch wenn
|
||||||
|
// der Request-Body durch ist — das wuerde zip vorzeitig killen.
|
||||||
|
res.on("close", () => {
|
||||||
|
if (!res.writableEnded && !zip.killed) zip.kill("SIGTERM");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
|
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
|
||||||
@@ -1702,6 +1756,68 @@ const server = http.createServer((req, res) => {
|
|||||||
});
|
});
|
||||||
req.pipe(proxyReq);
|
req.pipe(proxyReq);
|
||||||
return;
|
return;
|
||||||
|
} else if (req.url.startsWith("/api/chat-backup") && req.method === "GET") {
|
||||||
|
// Tail des chat_backup.jsonl — fuer Debug-Sessions (was hat ARIA wirklich
|
||||||
|
// gesagt/getan). ?lines=N (Default 200, Max 5000).
|
||||||
|
try {
|
||||||
|
const u = new URL(req.url, "http://localhost");
|
||||||
|
const lines = Math.max(1, Math.min(5000, parseInt(u.searchParams.get("lines") || "200", 10) || 200));
|
||||||
|
const file = "/shared/config/chat_backup.jsonl";
|
||||||
|
let raw = "";
|
||||||
|
try { raw = fs.readFileSync(file, "utf-8"); } catch {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ ok: true, file, lines: [] }));
|
||||||
|
}
|
||||||
|
const all = raw.split("\n").filter(l => l.trim());
|
||||||
|
const tail = all.slice(-lines);
|
||||||
|
const parsed = tail.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ ok: true, file, count: parsed.length, total: all.length, lines: parsed }));
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||||
|
}
|
||||||
|
} else if (req.url.startsWith("/api/agent-stream") && req.method === "GET") {
|
||||||
|
// Tail / paginierter Slice des persistierten agent_stream.jsonl.
|
||||||
|
// Modi:
|
||||||
|
// ?lines=N → letzte N Zeilen (Live-View Initial-Load)
|
||||||
|
// ?page=P&perPage=M → 1-indexed Pagination (Modal-Browser);
|
||||||
|
// page=1 = neueste Seite, hoehere Pages = aelter
|
||||||
|
try {
|
||||||
|
const u = new URL(req.url, "http://localhost");
|
||||||
|
const linesParam = u.searchParams.get("lines");
|
||||||
|
const pageParam = u.searchParams.get("page");
|
||||||
|
const perPageParam = u.searchParams.get("perPage");
|
||||||
|
const file = AGENT_STREAM_LOG;
|
||||||
|
let raw = "";
|
||||||
|
try { raw = fs.readFileSync(file, "utf-8"); } catch {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ ok: true, file, total: 0, lines: [] }));
|
||||||
|
}
|
||||||
|
const all = raw.split("\n").filter(l => l.trim());
|
||||||
|
let slice, page = 1, perPage = 0, pagesTotal = 1;
|
||||||
|
if (pageParam || perPageParam) {
|
||||||
|
perPage = Math.max(10, Math.min(5000, parseInt(perPageParam || "100", 10) || 100));
|
||||||
|
pagesTotal = Math.max(1, Math.ceil(all.length / perPage));
|
||||||
|
page = Math.max(1, Math.min(pagesTotal, parseInt(pageParam || "1", 10) || 1));
|
||||||
|
// page=1 = juengste Seite → vom Ende her slicen
|
||||||
|
const end = all.length - (page - 1) * perPage;
|
||||||
|
const start = Math.max(0, end - perPage);
|
||||||
|
slice = all.slice(start, end);
|
||||||
|
} else {
|
||||||
|
const lines = Math.max(1, Math.min(5000, parseInt(linesParam || "200", 10) || 200));
|
||||||
|
slice = all.slice(-lines);
|
||||||
|
}
|
||||||
|
const parsed = slice.map(l => { try { return JSON.parse(l); } catch { return { _raw: l }; } });
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({
|
||||||
|
ok: true, file, total: all.length, count: parsed.length,
|
||||||
|
page, perPage, pagesTotal, lines: parsed,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
return res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||||
|
}
|
||||||
} else if (req.url === "/api/brain-export" && req.method === "GET") {
|
} else if (req.url === "/api/brain-export" && req.method === "GET") {
|
||||||
// Komplettes Gehirn als tar.gz streamen.
|
// Komplettes Gehirn als tar.gz streamen.
|
||||||
// Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten.
|
// Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten.
|
||||||
@@ -1836,8 +1952,11 @@ wss.on("connection", (ws) => {
|
|||||||
const msg = JSON.parse(raw.toString());
|
const msg = JSON.parse(raw.toString());
|
||||||
|
|
||||||
if (msg.action === "test_gateway") {
|
if (msg.action === "test_gateway") {
|
||||||
traceStart("Gateway", msg.text || "aria lebst du noch?");
|
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
|
||||||
sendToGateway(msg.text || "aria lebst du noch?", true);
|
// alte Browser-Sessions die noch den Button anzeigen nicht stumm
|
||||||
|
// ins Leere klicken. Neue Versionen kennen den Button nicht mehr.
|
||||||
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||||
|
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||||
} else if (msg.action === "test_rvs") {
|
} else if (msg.action === "test_rvs") {
|
||||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||||
@@ -1885,6 +2004,18 @@ wss.on("connection", (ws) => {
|
|||||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||||
broadcast({ type: "agent_activity", activity: "idle" });
|
broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {});
|
||||||
|
} else if (msg.action === "aria_panic_stop") {
|
||||||
|
// NOT-AUS aus ARIA-Live-View: lokales /api/cancel UND Hard-Kill via
|
||||||
|
// Bridge (die wiederum den Proxy-Side-Channel /cancel-all anruft).
|
||||||
|
log("warn", "server", "⛔ NOT-AUS — hard cancel + proxy /cancel-all");
|
||||||
|
pendingMessageTime = 0;
|
||||||
|
watchdogWarned = false;
|
||||||
|
watchdogFixAttempted = false;
|
||||||
|
if (traceActive) traceEnd(false, "Vom Benutzer per NOT-AUS abgebrochen");
|
||||||
|
broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
|
// RVS-Broadcast cancel_request mit hard:true → aria-bridge ruft
|
||||||
|
// den Proxy-/cancel-all Side-Channel an, killt alle Subprocesses.
|
||||||
|
sendToRVS_raw({ type: "cancel_request", payload: { hard: true, source: "diagnostic-panic" }, timestamp: Date.now() });
|
||||||
} else if (msg.action === "voice_upload") {
|
} else if (msg.action === "voice_upload") {
|
||||||
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
|
// Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten
|
||||||
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
|
log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`);
|
||||||
@@ -1943,6 +2074,26 @@ wss.on("connection", (ws) => {
|
|||||||
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
|
if (msg.f5ttsNfeStep !== undefined && !isNaN(msg.f5ttsNfeStep)) {
|
||||||
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
|
voiceConfig.f5ttsNfeStep = msg.f5ttsNfeStep;
|
||||||
}
|
}
|
||||||
|
// FLUX-Settings (Default-Modell + User-Keywords). flux-bridge nutzt
|
||||||
|
// fluxDefaultModel zum Hot-Swap, Brain liest die Keywords direkt aus
|
||||||
|
// /shared/config/voice_config.json fuer den System-Prompt.
|
||||||
|
if (msg.fluxDefaultModel !== undefined) {
|
||||||
|
voiceConfig.fluxDefaultModel = (msg.fluxDefaultModel === "schnell") ? "schnell" : "dev";
|
||||||
|
}
|
||||||
|
if (msg.fluxKeywordRaw !== undefined) {
|
||||||
|
voiceConfig.fluxKeywordRaw = String(msg.fluxKeywordRaw || "").trim().toLowerCase() || "flux";
|
||||||
|
}
|
||||||
|
if (msg.fluxKeywordSwitch !== undefined) {
|
||||||
|
voiceConfig.fluxKeywordSwitch = String(msg.fluxKeywordSwitch || "").trim().toLowerCase() || "fix";
|
||||||
|
}
|
||||||
|
// HuggingFace-Token fuer gated FLUX.1-dev. Wird per RVS an die
|
||||||
|
// flux-bridge gepusht, dort als HF_TOKEN env gesetzt vor dem
|
||||||
|
// naechsten from_pretrained. Leerer String = "kein Token" (statt
|
||||||
|
// 'behalt was du hattest'), damit Stefan ihn auch wieder loeschen
|
||||||
|
// kann.
|
||||||
|
if (msg.huggingfaceToken !== undefined) {
|
||||||
|
voiceConfig.huggingfaceToken = String(msg.huggingfaceToken || "").trim();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync("/shared/config", { recursive: true });
|
fs.mkdirSync("/shared/config", { recursive: true });
|
||||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||||
|
|||||||
+22
-7
@@ -12,13 +12,15 @@ services:
|
|||||||
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
|
||||||
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
|
||||||
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||||
|
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
|
||||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
|
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||||
claude-max-api"
|
claude-max-api"
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
||||||
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
|
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
|
||||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
|
- ./aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
|
||||||
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
|
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
|
||||||
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
|
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
|
||||||
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
|
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
|
||||||
@@ -65,11 +67,27 @@ services:
|
|||||||
- QDRANT_PORT=6333
|
- QDRANT_PORT=6333
|
||||||
- PROXY_URL=http://proxy:3456
|
- PROXY_URL=http://proxy:3456
|
||||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||||
|
# Read-Timeout fuer den Proxy-Call. Hoch, weil Agent-Loops (Pentests
|
||||||
|
# etc.) auch eine Stunde+ dauern koennen. Der Proxy seinerseits hat
|
||||||
|
# einen Idle-Watchdog (Default 20min Inaktivitaet) der den Subprocess
|
||||||
|
# killt, der dann seinen close-Event sendet — Brain bekommt also
|
||||||
|
# immer was zurueck, auch bei wirklich haengenden Subprozessen.
|
||||||
|
# Connect/Write/Pool sind klein (10/30/10s) damit toter Proxy
|
||||||
|
# schnell erkannt wird (siehe proxy_client.py).
|
||||||
|
- PROXY_TIMEOUT_SEC=${PROXY_TIMEOUT_SEC:-86400}
|
||||||
|
# OAuth-Callback-URL Bestandteile. Brain baut daraus
|
||||||
|
# https://{RVS_HOST}:{RVS_PORT_PUBLIC}/oauth/callback/{service} als
|
||||||
|
# redirect_uri fuer Provider wie Spotify/Google/etc. RVS_PORT_PUBLIC
|
||||||
|
# ist der nach aussen exposed Port (= TLS-Port hinter Caddy/Nginx),
|
||||||
|
# nicht der interne RVS-Container-Port.
|
||||||
|
- RVS_HOST=${RVS_HOST:-}
|
||||||
|
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
|
||||||
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
volumes:
|
volumes:
|
||||||
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
|
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
|
||||||
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
|
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
|
||||||
- ./aria-data/ssh:/root/.ssh # SSH-Keys fuer aria-wohnung (geteilt mit Proxy)
|
- ./aria-data/ssh:/root/.ssh # SSH-Keys fuer aria-wohnung (geteilt mit Proxy)
|
||||||
- aria-shared:/shared # gleicher Austausch-Speicher wie Bridge
|
- ./aria-shared:/shared # gleicher Austausch-Speicher wie Bridge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- aria-net
|
- aria-net
|
||||||
@@ -85,7 +103,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
||||||
volumes:
|
volumes:
|
||||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch
|
- ./aria-shared:/shared # Shared Volume fuer Datei-Austausch
|
||||||
# Audio-Zugriff
|
# Audio-Zugriff
|
||||||
- /run/user/1000/pulse:/run/user/1000/pulse
|
- /run/user/1000/pulse:/run/user/1000/pulse
|
||||||
- /dev/snd:/dev/snd
|
- /dev/snd:/dev/snd
|
||||||
@@ -114,7 +132,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock # Container Restart + Brain-Export/Import
|
- /var/run/docker.sock:/var/run/docker.sock # Container Restart + Brain-Export/Import
|
||||||
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
|
- ./aria-data/config/diag-state:/data # Persistenter State (aktive Session etc.)
|
||||||
- aria-shared:/shared # Shared Volume (Uploads + Config + Voices)
|
- ./aria-shared:/shared # Shared Volume (Uploads + Config + Voices)
|
||||||
- ./aria-data/brain:/brain # Brain-Export/Import (tar.gz aus Bind-Mount)
|
- ./aria-data/brain:/brain # Brain-Export/Import (tar.gz aus Bind-Mount)
|
||||||
environment:
|
environment:
|
||||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||||
@@ -127,9 +145,6 @@ services:
|
|||||||
- RVS_TOKEN=${RVS_TOKEN:-}
|
- RVS_TOKEN=${RVS_TOKEN:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
aria-shared: # Datei-Austausch zwischen Bridge / Brain / Diagnostic
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
aria-net:
|
aria-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# FLUX.1-dev Bildgenerierung — Architektur & Stand
|
||||||
|
|
||||||
|
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
|
||||||
|
FLUX.1-dev auf der Gamebox. Folgt dem **gleichen Pattern wie f5tts / whisper**:
|
||||||
|
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
|
||||||
|
RVS verbindet und auf seinen Request-Typ lauscht.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Stefan / App
|
||||||
|
│ Chat-Nachricht ("mal mir einen Sonnenuntergang ueberm Hangar")
|
||||||
|
▼
|
||||||
|
aria-bridge ── send_to_core ──▶ aria-brain
|
||||||
|
│ chooses tool: flux_generate(prompt=..., width=..., ...)
|
||||||
|
│ POST /internal/flux-generate
|
||||||
|
▼
|
||||||
|
aria-bridge (VM)
|
||||||
|
│ pushes {type: "flux_request",
|
||||||
|
│ payload: {requestId, prompt, ...}}
|
||||||
|
│ via RVS-Broadcast
|
||||||
|
▼
|
||||||
|
RVS
|
||||||
|
│ fanout
|
||||||
|
▼
|
||||||
|
flux-bridge (Gamebox)
|
||||||
|
│ FluxPipeline.from_pretrained(...)
|
||||||
|
│ pipeline(prompt, width, height, steps, guidance).images[0]
|
||||||
|
│ PIL → PNG → base64
|
||||||
|
│ {type: "flux_response", payload: {state:"done",
|
||||||
|
│ requestId, base64, mimeType, ...}}
|
||||||
|
▼
|
||||||
|
RVS
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
aria-bridge (VM)
|
||||||
|
│ _pending_flux[requestId].set_result(payload)
|
||||||
|
│ base64-decode → /shared/uploads/aria_generated_<ts>.png
|
||||||
|
│ HTTP 200 zurueck an Brain mit {path, sizeBytes, ...}
|
||||||
|
▼
|
||||||
|
aria-brain
|
||||||
|
│ Tool-Result + Hint: "schreib [FILE: {path}] in deine Antwort"
|
||||||
|
│ Final-Reply: "Hier dein Bild:\n[FILE: /shared/uploads/aria_generated_<ts>.png]"
|
||||||
|
▼
|
||||||
|
aria-bridge
|
||||||
|
│ _FILE_MARKER_RE → file_from_aria-Event
|
||||||
|
│ Marker bleibt im Chat-Text fuer Hist; App rendert das Bild inline
|
||||||
|
▼
|
||||||
|
App + Diagnostic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
### 1. `flux/bridge.py` (neu) — flux-bridge Container
|
||||||
|
|
||||||
|
- `FluxPipeline` (diffusers) mit `enable_model_cpu_offload()` als Default,
|
||||||
|
damit FLUX.1-dev (~24 GB on disk, ~12 B params) auf einer RTX 3060
|
||||||
|
(12 GB VRAM) ueberhaupt laeuft.
|
||||||
|
- Lazy-Load: Modell wird beim ersten `flux_request` (oder im Initial-Load)
|
||||||
|
geladen, `service_status: "flux", state: "loading" | "ready" | "error"`
|
||||||
|
wird via RVS broadcastet → Diagnostic-Badge zeigt's an.
|
||||||
|
- Single-Worker-Queue (`_flux_queue`) — GPU darf nicht parallel rendern,
|
||||||
|
sonst OOM oder Crash.
|
||||||
|
- Progress-Ping: `flux_response {state: "rendering"}` direkt nach
|
||||||
|
Queue-Pickup, damit die aria-bridge weiss "Auftrag angekommen", auch
|
||||||
|
wenn der eigentliche Render 60s braucht.
|
||||||
|
- Caps:
|
||||||
|
- `width`/`height`: 256 .. `FLUX_MAX_DIM` (Default 1536), gesnappt auf
|
||||||
|
Vielfache von 64.
|
||||||
|
- `steps`: 1 .. `FLUX_MAX_STEPS` (Default 50).
|
||||||
|
- `guidance_scale`: 0.0 .. 20.0.
|
||||||
|
- `prompt`: max 2000 chars.
|
||||||
|
- Env-Switches:
|
||||||
|
- `FLUX_MODEL` — Default `black-forest-labs/FLUX.1-dev` (non-commercial).
|
||||||
|
Alt: `FLUX.1-schnell` (Apache-2.0, 4 Steps, deutlich schneller).
|
||||||
|
- `FLUX_OFFLOAD` — `model` (default), `sequential` (sparsamer, langsamer)
|
||||||
|
oder `none` (alles auf GPU; nur fuer >=24 GB VRAM-Karten).
|
||||||
|
- `FLUX_DTYPE` — `bfloat16` (default) oder `float16`.
|
||||||
|
- `HF_TOKEN` — FLUX.1-dev braucht HuggingFace-Login.
|
||||||
|
|
||||||
|
### 2. `flux/docker-compose.yml` — eigener Stack
|
||||||
|
|
||||||
|
Bewusst NICHT mit in `xtts/docker-compose.yml` gepackt: FLUX kann auch
|
||||||
|
separat laufen (z.B. spaeter auf einer 4090, waehrend die 3060 weiter
|
||||||
|
TTS+STT bedient). Eigener Compose, eigene `.env.example`, eigenes
|
||||||
|
`hf-cache/`-Volume.
|
||||||
|
|
||||||
|
- GPU-Reservation analog zu f5tts/whisper.
|
||||||
|
- Volume `./hf-cache:/root/.cache/huggingface` — wenn flux auf der
|
||||||
|
gleichen Maschine wie xtts laeuft kann man `../xtts/hf-cache`
|
||||||
|
symlinken, dann ist der Modell-Cache geteilt.
|
||||||
|
- Restart `unless-stopped`.
|
||||||
|
|
||||||
|
### 3. `rvs/server.js` — Allowlist erweitert
|
||||||
|
|
||||||
|
Neue Typen: `flux_request`, `flux_response` (auch wenn das Initial-Load-
|
||||||
|
broadcast `service_status` bereits zugelassen war).
|
||||||
|
|
||||||
|
### 4. `bridge/aria_bridge.py`
|
||||||
|
|
||||||
|
- `self._pending_flux: dict[str, asyncio.Future]` — request_id → future.
|
||||||
|
- `self._remote_flux_ready: bool` — wird von `service_status` Updates
|
||||||
|
gefuellt; steuert den HTTP-Timeout (240 s wenn ready, 900 s waehrend
|
||||||
|
des allerersten Modell-Downloads).
|
||||||
|
- `flux_response`-Handler: Progress-Ping (`state == "rendering"`) bleibt
|
||||||
|
no-op auf der Future; `state == "done"` setzt die Future, Error setzt
|
||||||
|
`{"error": ...}`.
|
||||||
|
- `_flux_generate(prompt, width, height, steps, guidance, seed)` — Helper:
|
||||||
|
1. UUID + Future
|
||||||
|
2. `flux_request` broadcasten
|
||||||
|
3. `asyncio.wait_for(future, timeout=...)`
|
||||||
|
4. base64 → `/shared/uploads/aria_generated_<ts>.png`
|
||||||
|
5. dict mit `{ok, path, sizeBytes, width, height, steps, guidance, seed, model, renderSeconds}`
|
||||||
|
- HTTP-Endpoint `POST /internal/flux-generate` im internen Listener
|
||||||
|
(Port 8090). Validiert prompt + clamps, ruft `_flux_generate`, gibt
|
||||||
|
Result als JSON zurueck.
|
||||||
|
|
||||||
|
### 5. `aria-brain/agent.py` — META-Tool `flux_generate`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "flux_generate",
|
||||||
|
"parameters": {
|
||||||
|
"prompt": "string (englischer Prompt — FLUX liefert auf EN besser)",
|
||||||
|
"width": "integer (256..1536, default 1024)",
|
||||||
|
"height": "integer (256..1536, default 1024)",
|
||||||
|
"steps": "integer (1..50, default 28)",
|
||||||
|
"guidance_scale": "number (default 3.5)",
|
||||||
|
"seed": "integer (optional)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dispatcher:
|
||||||
|
- POSTet `{prompt, width, height, steps, guidance_scale, seed}` an
|
||||||
|
`http://aria-bridge:8090/internal/flux-generate` (urllib, 1200 s Timeout
|
||||||
|
— der erste Render kann den 24 GB Modell-Download triggern).
|
||||||
|
- Bei `ok=true` gibt das Tool den **Pfad** + Render-Stats zurueck und
|
||||||
|
weist Claude explizit an: *"Schreibe `[FILE: <path>]` in deine
|
||||||
|
Antwort an Stefan, dann zeigt die App das Bild inline."*
|
||||||
|
- Brain ueberlegt sich den Begleittext selber und packt den Marker an
|
||||||
|
passende Stelle.
|
||||||
|
|
||||||
|
### 6. `diagnostic/index.html` — Status-Badge
|
||||||
|
|
||||||
|
Label `flux: 'FLUX Image-Gen'` zum bestehenden `updateServiceStatus()`-Switch
|
||||||
|
hinzugefuegt — kein neuer Code, gleicher Banner-Mechanismus wie F5-TTS /
|
||||||
|
Whisper.
|
||||||
|
|
||||||
|
## File-Lifecycle
|
||||||
|
|
||||||
|
Generierte Bilder leben unter `/shared/uploads/aria_generated_<ts>.png`
|
||||||
|
(gleicher Folder wie User-Uploads). Damit:
|
||||||
|
- `[FILE: ...]`-Marker funktioniert (Bridge erlaubt nur Pfade unter
|
||||||
|
`/shared/uploads/`).
|
||||||
|
- File-Manager-Endpoints in Diagnostic (Liste/Loeschen/Zip) sehen sie
|
||||||
|
ohne Sonderbehandlung.
|
||||||
|
- Memory-Anhaenge: ARIA kann ein generiertes Bild im selben Turn an
|
||||||
|
einen Memory-Eintrag haengen (`memory_save(attach_paths=[path])`).
|
||||||
|
|
||||||
|
## Bekannte Stolpersteine
|
||||||
|
|
||||||
|
- **HF-Login**: FLUX.1-dev ist gated. Vor erstem Start `HF_TOKEN` im
|
||||||
|
`.env` setzen oder im Container `huggingface-cli login` machen, sonst
|
||||||
|
403 beim ersten Download.
|
||||||
|
- **Erster Render dauert lang**: 24 GB Modell laden + CUDA-Warmup → 5-10
|
||||||
|
min realistisch. Brain-HTTP-Timeout ist 1200 s, RVS-Future-Timeout
|
||||||
|
900 s (loading-Modus). Stefan sollte beim ersten "Mal mir was"-Request
|
||||||
|
ein bisschen Geduld haben — danach sind Renders ~30-90 s.
|
||||||
|
- **Lizenz**: FLUX.1-dev ist *non-commercial* (FLUX.1 Dev Non-Commercial
|
||||||
|
License). Fuer kommerzielle Nutzung muss man auf `FLUX.1-schnell`
|
||||||
|
(Apache-2.0) oder `FLUX.1-pro` (API only) wechseln. Stefan kann das
|
||||||
|
ueber `FLUX_MODEL` in der `.env` umstellen.
|
||||||
|
- **VRAM**: 12 GB (3060) reichen NUR mit `enable_model_cpu_offload`. Bei
|
||||||
|
Out-of-Memory in den Logs auf `FLUX_OFFLOAD=sequential` switchen
|
||||||
|
(deutlich langsamer, aber peak-VRAM ~6 GB).
|
||||||
|
- **Parallele Calls**: Single-Worker-Queue in der flux-bridge — ein
|
||||||
|
zweiter `flux_generate`-Tool-Call von Brain wartet, bis der erste fertig
|
||||||
|
ist. In der Praxis kein Problem, weil Stefan eh nicht zwei Bilder
|
||||||
|
gleichzeitig macht.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ════════════════════════════════════════════════
|
||||||
|
# ARIA FLUX-Bridge — Konfiguration
|
||||||
|
# Kopieren nach .env und anpassen
|
||||||
|
# ════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM / xtts/.env)
|
||||||
|
RVS_HOST=mobil.hacker-net.de
|
||||||
|
RVS_PORT=444
|
||||||
|
RVS_TLS=true
|
||||||
|
RVS_TLS_FALLBACK=true
|
||||||
|
RVS_TOKEN=dein_token_hier
|
||||||
|
|
||||||
|
# HuggingFace-Token + Default-Modell werden in ARIA Diagnostic verwaltet
|
||||||
|
# (Section "FLUX Bildgenerierung") und per RVS an die flux-bridge gepusht.
|
||||||
|
# Hier nichts noetig.
|
||||||
|
#
|
||||||
|
# Token-Pflicht NUR fuer FLUX.1-dev (gated). Workflow falls Du dev nutzen
|
||||||
|
# willst:
|
||||||
|
# 1) https://huggingface.co/black-forest-labs/FLUX.1-dev → "Agree"
|
||||||
|
# 2) https://huggingface.co/settings/tokens → "Read"-Token erzeugen
|
||||||
|
# 3) Token in Diagnostic > FLUX Bildgenerierung > HuggingFace-Token
|
||||||
|
# FLUX.1-schnell (Apache-2.0) laeuft ohne Token.
|
||||||
|
|
||||||
|
# Offloading-Strategie (VRAM-Steuerung):
|
||||||
|
# model — Default. Komponentenweise CPU-Offload, gut fuer 12 GB Karten.
|
||||||
|
# sequential — sparsamer (Peak ~6 GB), aber 2-3x langsamer.
|
||||||
|
# none — alles auf GPU. Nur fuer >= 24 GB VRAM-Karten.
|
||||||
|
FLUX_OFFLOAD=model
|
||||||
|
|
||||||
|
# Float-Type. bfloat16 ist FLUX-native; auf alten Karten ohne BF16-Support
|
||||||
|
# auf float16 wechseln.
|
||||||
|
FLUX_DTYPE=bfloat16
|
||||||
|
|
||||||
|
# Hard-Caps gegen versehentlich teure Renders
|
||||||
|
FLUX_MAX_STEPS=50
|
||||||
|
FLUX_MAX_DIM=1536
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# HuggingFace Model-Cache (FLUX.1-dev ~24 GB on disk)
|
||||||
|
hf-cache/
|
||||||
|
|
||||||
|
# Docker .env
|
||||||
|
.env
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 python3-pip git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# PyTorch CUDA-Wheels zuerst, damit diffusers nicht CPU-Torch zieht.
|
||||||
|
# Torch 2.5+ ist Pflicht: aktuelle transformers (4.50+, von diffusers
|
||||||
|
# transitiv reingezogen) registriert in integrations/moe.py einen
|
||||||
|
# custom_op mit String-Forward-References (`input: 'torch.Tensor'`).
|
||||||
|
# Erst torch 2.5's infer_schema kann die aufloesen — 2.4.1 crasht mit
|
||||||
|
# "Parameter input has unsupported type torch.Tensor" beim Import von
|
||||||
|
# diffusers.pipelines.flux.pipeline_flux.
|
||||||
|
# torchvision wird von den CLIP-/Siglip-ImageProcessors verlangt.
|
||||||
|
# cu121 bleibt — passt zum CUDA 12.2 Base-Image.
|
||||||
|
RUN pip3 install --no-cache-dir \
|
||||||
|
torch==2.5.1 torchvision==0.20.1 \
|
||||||
|
--index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY bridge.py .
|
||||||
|
|
||||||
|
CMD ["python3", "bridge.py"]
|
||||||
+557
@@ -0,0 +1,557 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ARIA FLUX-Bridge — laeuft auf der Gamebox (RTX 3060).
|
||||||
|
|
||||||
|
Empfaengt flux_request via RVS → FLUX.1-dev/-schnell auf GPU → sendet
|
||||||
|
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
|
||||||
|
die Datei nach /shared/uploads/ und ARIA referenziert sie mit
|
||||||
|
[FILE: ...]-Marker in ihrer Antwort.
|
||||||
|
|
||||||
|
12 GB VRAM auf der 3060 reichen fuer FLUX.1-dev nur mit
|
||||||
|
`enable_model_cpu_offload()` — sonst OOM. Setze FLUX_OFFLOAD=sequential
|
||||||
|
fuer Maximal-Sparsamkeit (langsamer) oder FLUX_OFFLOAD=none wenn die
|
||||||
|
GPU genug VRAM hat (z.B. spaeter 4090).
|
||||||
|
|
||||||
|
Env:
|
||||||
|
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||||
|
FLUX_MODEL Default: black-forest-labs/FLUX.1-dev
|
||||||
|
Alt: black-forest-labs/FLUX.1-schnell (4-Step, Apache-2.0)
|
||||||
|
FLUX_DEVICE Default: cuda
|
||||||
|
FLUX_DTYPE Default: bfloat16 (alt: float16)
|
||||||
|
FLUX_OFFLOAD Default: model (alt: sequential | none)
|
||||||
|
FLUX_MAX_STEPS Default: 50
|
||||||
|
FLUX_MAX_DIM Default: 1536
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("flux-bridge")
|
||||||
|
# HuggingFace/Torch download-Logs daempfen
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
RVS_HOST = os.getenv("RVS_HOST", "").strip()
|
||||||
|
RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
||||||
|
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||||
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
|
||||||
|
# Bootstrap-Fallback: nur relevant wenn beim allerersten Start KEIN
|
||||||
|
# Diagnostic-config-Broadcast eintrifft UND der erste Render-Request
|
||||||
|
# auch kein 'model' enthaelt. Default 'schnell', weil Apache-2.0
|
||||||
|
# (kein HF-Token noetig) — Stefan stellt sein gewuenschtes Default ueber
|
||||||
|
# Diagnostic ein. ENV ist also nur fuer den extremen Edge-Case da, in
|
||||||
|
# der .env.example absichtlich nicht mehr dokumentiert.
|
||||||
|
FLUX_MODEL = os.getenv("FLUX_MODEL", "black-forest-labs/FLUX.1-schnell").strip()
|
||||||
|
FLUX_DEVICE = os.getenv("FLUX_DEVICE", "cuda").strip()
|
||||||
|
FLUX_DTYPE = os.getenv("FLUX_DTYPE", "bfloat16").strip().lower()
|
||||||
|
FLUX_OFFLOAD = os.getenv("FLUX_OFFLOAD", "model").strip().lower()
|
||||||
|
FLUX_MAX_STEPS = int(os.getenv("FLUX_MAX_STEPS", "50"))
|
||||||
|
FLUX_MAX_DIM = int(os.getenv("FLUX_MAX_DIM", "1536"))
|
||||||
|
|
||||||
|
# FLUX-dev native: guidance=3.5, steps=28. FLUX-schnell: guidance=0.0, steps=4.
|
||||||
|
DEFAULT_STEPS_DEV = 28
|
||||||
|
DEFAULT_STEPS_SCHNELL = 4
|
||||||
|
DEFAULT_GUIDANCE_DEV = 3.5
|
||||||
|
DEFAULT_GUIDANCE_SCHNELL = 0.0
|
||||||
|
|
||||||
|
# Mapping fuer das User-facing Tag → HF-Modell-ID. Stefan stellt in Diagnostic
|
||||||
|
# nur 'dev' / 'schnell' ein; FLUX_MODEL aus der env kann zwar eine custom-ID
|
||||||
|
# sein (Bootstrap), wird aber beim ersten config-Broadcast normalerweise
|
||||||
|
# durch die Diagnostic-Wahl uebersteuert.
|
||||||
|
MODEL_TAGS: dict[str, str] = {
|
||||||
|
"dev": "black-forest-labs/FLUX.1-dev",
|
||||||
|
"schnell": "black-forest-labs/FLUX.1-schnell",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_to_model_id(tag: str) -> str:
|
||||||
|
"""Mappt 'dev'/'schnell' auf HF-ID. Andere Strings werden 1:1 durchgereicht
|
||||||
|
(custom-IDs aus FLUX_MODEL env). Leere/ungueltige Werte → FLUX_MODEL Default."""
|
||||||
|
if not tag:
|
||||||
|
return FLUX_MODEL
|
||||||
|
t = tag.strip()
|
||||||
|
return MODEL_TAGS.get(t, t)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_schnell(model_id: str) -> bool:
|
||||||
|
return "schnell" in model_id.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_model_cached(model_id: str) -> bool:
|
||||||
|
"""Prueft ob ein HF-Modell-Snapshot lokal im hf-cache vorhanden ist.
|
||||||
|
|
||||||
|
HF speichert unter ~/.cache/huggingface/hub/models--{org}--{name}/snapshots/{rev}/.
|
||||||
|
Wenn das snapshots-Verzeichnis nicht existiert oder leer ist → Erst-Download
|
||||||
|
steht an (24+ GB fuer FLUX.1-dev, 24+ GB fuer FLUX.1-schnell — Stefan kriegt
|
||||||
|
dann nen Hinweis im Banner).
|
||||||
|
"""
|
||||||
|
if not model_id:
|
||||||
|
return False
|
||||||
|
cache_root = os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface")
|
||||||
|
safe = "models--" + model_id.replace("/", "--")
|
||||||
|
snapshots = os.path.join(cache_root, "hub", safe, "snapshots")
|
||||||
|
if not os.path.isdir(snapshots):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
for rev in os.listdir(snapshots):
|
||||||
|
rev_dir = os.path.join(snapshots, rev)
|
||||||
|
if os.path.isdir(rev_dir) and any(os.scandir(rev_dir)):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _torch_dtype():
|
||||||
|
"""Lazy-resolve damit Torch erst beim Modell-Laden importiert wird."""
|
||||||
|
import torch
|
||||||
|
return {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}\
|
||||||
|
.get(FLUX_DTYPE, torch.bfloat16)
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_dim(v: int, default: int = 1024) -> int:
|
||||||
|
"""FLUX braucht Multiples von 16 (sicher: 64). Clamp + Snap."""
|
||||||
|
try:
|
||||||
|
n = int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = default
|
||||||
|
n = max(256, min(FLUX_MAX_DIM, n))
|
||||||
|
# Auf naechstes Vielfaches von 64 abrunden
|
||||||
|
n = (n // 64) * 64
|
||||||
|
return max(256, n)
|
||||||
|
|
||||||
|
|
||||||
|
class FluxRunner:
|
||||||
|
"""Haelt EINE FLUX-Pipeline. Bei Modell-Wechsel wird die alte verworfen
|
||||||
|
und die neue geladen (~15-30 s aus HF-Cache, keine Re-Downloads).
|
||||||
|
|
||||||
|
Pro Request kann ein 'dev'/'schnell'-Tag mitkommen; ohne Angabe wird
|
||||||
|
`default_model_id` genommen (steht Bootstrap auf FLUX_MODEL, wird beim
|
||||||
|
ersten config-Broadcast von der aria-bridge auf die Diagnostic-Wahl
|
||||||
|
aktualisiert).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.pipe = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
# Aktuell geladenes Modell — leer solange noch nix geladen wurde.
|
||||||
|
self.model_id: str = ""
|
||||||
|
# Was bei einem Request OHNE explizite model-Angabe benutzt wird.
|
||||||
|
# Wird durch Diagnostic-config gesetzt; FLUX_MODEL bleibt nur als
|
||||||
|
# Edge-Case-Fallback wenn weder Config noch Request einen Wert nennen.
|
||||||
|
self.default_model_id: str = FLUX_MODEL
|
||||||
|
self.last_load_seconds: float = 0.0
|
||||||
|
# True wenn der letzte _load_blocking einen Fresh-Download triggern
|
||||||
|
# musste (Modell war nicht im HF-Cache). Wird vom Caller geprueft
|
||||||
|
# und in den 'ready'-service_status als freshlyDownloaded gesetzt.
|
||||||
|
self.last_load_was_download: bool = False
|
||||||
|
|
||||||
|
def _load_blocking(self, model_id: str) -> None:
|
||||||
|
import torch
|
||||||
|
from diffusers import FluxPipeline
|
||||||
|
|
||||||
|
# Alte Pipeline freigeben damit der HF-Loader VRAM/RAM kriegt
|
||||||
|
if self.pipe is not None:
|
||||||
|
logger.info("Verwerfe alte Pipeline '%s'", self.model_id)
|
||||||
|
try:
|
||||||
|
del self.pipe
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.pipe = None
|
||||||
|
try:
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
was_cached = _is_model_cached(model_id)
|
||||||
|
self.last_load_was_download = not was_cached
|
||||||
|
if not was_cached:
|
||||||
|
logger.warning("FLUX '%s' nicht im HF-Cache — Erst-Download steht bevor (kann 5-10 min dauern).",
|
||||||
|
model_id)
|
||||||
|
logger.info("Lade FLUX '%s' (dtype=%s, offload=%s, cached=%s)...",
|
||||||
|
model_id, FLUX_DTYPE, FLUX_OFFLOAD, was_cached)
|
||||||
|
t0 = time.time()
|
||||||
|
pipe = FluxPipeline.from_pretrained(model_id, torch_dtype=_torch_dtype())
|
||||||
|
|
||||||
|
if FLUX_OFFLOAD == "sequential":
|
||||||
|
pipe.enable_sequential_cpu_offload()
|
||||||
|
elif FLUX_OFFLOAD == "none":
|
||||||
|
pipe.to(FLUX_DEVICE)
|
||||||
|
else: # "model" — default, Sweet-Spot fuer 12 GB Karten
|
||||||
|
pipe.enable_model_cpu_offload()
|
||||||
|
|
||||||
|
# VAE-Tiling spart VRAM bei grossen Bildern (>1024)
|
||||||
|
try:
|
||||||
|
pipe.vae.enable_tiling()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.pipe = pipe
|
||||||
|
self.model_id = model_id
|
||||||
|
self.last_load_seconds = time.time() - t0
|
||||||
|
logger.info("FLUX '%s' geladen in %.1fs", model_id, self.last_load_seconds)
|
||||||
|
try:
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def ensure_loaded(self, model_id: Optional[str] = None) -> bool:
|
||||||
|
"""Stellt sicher dass die richtige Pipeline geladen ist. Wenn ein
|
||||||
|
anderes Modell gewuenscht ist als gerade aktiv, wird geswappt.
|
||||||
|
Returns True wenn ein Swap/Load stattgefunden hat."""
|
||||||
|
target = model_id or self.default_model_id or FLUX_MODEL
|
||||||
|
async with self._lock:
|
||||||
|
if self.pipe is not None and self.model_id == target:
|
||||||
|
return False
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._load_blocking, target)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _generate_blocking(self, prompt: str, width: int, height: int,
|
||||||
|
steps: int, guidance: float, seed: Optional[int]) -> bytes:
|
||||||
|
import torch
|
||||||
|
gen = None
|
||||||
|
if seed is not None and seed >= 0:
|
||||||
|
gen = torch.Generator(device=FLUX_DEVICE).manual_seed(int(seed))
|
||||||
|
|
||||||
|
logger.info("Render (%s): %dx%d, steps=%d, guidance=%.2f, seed=%s, prompt=%r",
|
||||||
|
self.model_id, width, height, steps, guidance, seed, prompt[:80])
|
||||||
|
out = self.pipe(
|
||||||
|
prompt=prompt,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=guidance,
|
||||||
|
generator=gen,
|
||||||
|
)
|
||||||
|
image = out.images[0]
|
||||||
|
buf = io.BytesIO()
|
||||||
|
image.save(buf, format="PNG", optimize=True)
|
||||||
|
png_bytes = buf.getvalue()
|
||||||
|
# VRAM zurueckgeben fuer den naechsten Render
|
||||||
|
try:
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return png_bytes
|
||||||
|
|
||||||
|
async def generate(self, prompt: str, width: int, height: int,
|
||||||
|
steps: int, guidance: float, seed: Optional[int],
|
||||||
|
model_id: Optional[str] = None) -> bytes:
|
||||||
|
await self.ensure_loaded(model_id)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None, self._generate_blocking, prompt, width, height, steps, guidance, seed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": mtype,
|
||||||
|
"payload": payload,
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||||
|
"""Sendet service_status fuer das Flux-Modul.
|
||||||
|
state: 'loading' | 'ready' | 'error'."""
|
||||||
|
payload = {"service": "flux", "state": state}
|
||||||
|
payload.update(extra)
|
||||||
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Flux-Request Queue ──────────────────────────────────────
|
||||||
|
|
||||||
|
# Eine GPU, ein Render gleichzeitig. Parallele Requests OOM-en sonst.
|
||||||
|
_flux_queue: "asyncio.Queue[tuple]" = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_request(payload: dict, runner: FluxRunner) -> tuple[str, int, int, int, float, Optional[int], str]:
|
||||||
|
"""Liest Felder aus dem flux_request payload + clampt auf Caps.
|
||||||
|
Returns (prompt, width, height, steps, guidance, seed, resolved_model_id).
|
||||||
|
"""
|
||||||
|
prompt = (payload.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
raise ValueError("prompt fehlt")
|
||||||
|
if len(prompt) > 2000:
|
||||||
|
prompt = prompt[:2000]
|
||||||
|
|
||||||
|
width = _snap_dim(payload.get("width", 1024))
|
||||||
|
height = _snap_dim(payload.get("height", 1024))
|
||||||
|
|
||||||
|
# Modell-Wahl: explizit per Request > runner.default_model_id > FLUX_MODEL.
|
||||||
|
req_model = (payload.get("model") or "").strip()
|
||||||
|
resolved_model_id = _tag_to_model_id(req_model) if req_model else (runner.default_model_id or FLUX_MODEL)
|
||||||
|
|
||||||
|
schnell = _is_schnell(resolved_model_id)
|
||||||
|
default_steps = DEFAULT_STEPS_SCHNELL if schnell else DEFAULT_STEPS_DEV
|
||||||
|
default_guidance = DEFAULT_GUIDANCE_SCHNELL if schnell else DEFAULT_GUIDANCE_DEV
|
||||||
|
|
||||||
|
try:
|
||||||
|
steps = int(payload.get("steps", default_steps))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
steps = default_steps
|
||||||
|
steps = max(1, min(FLUX_MAX_STEPS, steps))
|
||||||
|
|
||||||
|
try:
|
||||||
|
guidance = float(payload.get("guidance_scale", default_guidance))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
guidance = default_guidance
|
||||||
|
if not (0.0 <= guidance <= 20.0):
|
||||||
|
guidance = default_guidance
|
||||||
|
|
||||||
|
seed = payload.get("seed")
|
||||||
|
if seed is not None:
|
||||||
|
try:
|
||||||
|
seed = int(seed)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
seed = None
|
||||||
|
|
||||||
|
return prompt, width, height, steps, guidance, seed, resolved_model_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _flux_worker(ws, runner: FluxRunner) -> None:
|
||||||
|
"""Serialisiert Renders — eine GPU, ein Bild gleichzeitig."""
|
||||||
|
while True:
|
||||||
|
payload = await _flux_queue.get()
|
||||||
|
request_id = payload.get("requestId") or str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
await _do_render(ws, runner, payload, request_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Flux-Worker Fehler")
|
||||||
|
await _send(ws, "flux_response", {
|
||||||
|
"requestId": request_id,
|
||||||
|
"error": "internal error",
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
_flux_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_render(ws, runner: FluxRunner, payload: dict, request_id: str) -> None:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
prompt, width, height, steps, guidance, seed, target_model_id = _resolve_request(payload, runner)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning("flux_request invalid: %s", e)
|
||||||
|
await _send(ws, "flux_response", {"requestId": request_id, "error": str(e)})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Modell-Swap noetig? Status broadcasten damit Diagnostic-Banner es zeigt.
|
||||||
|
swap_needed = (runner.pipe is None or runner.model_id != target_model_id)
|
||||||
|
will_download = swap_needed and not _is_model_cached(target_model_id)
|
||||||
|
if swap_needed:
|
||||||
|
await _broadcast_status(ws, "loading", model=target_model_id,
|
||||||
|
downloading=will_download)
|
||||||
|
await _send(ws, "flux_response", {
|
||||||
|
"requestId": request_id,
|
||||||
|
"state": "switching_model",
|
||||||
|
"model": target_model_id,
|
||||||
|
"downloading": will_download,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Progress-Ping: User soll sehen dass was passiert (Render >30s realistisch)
|
||||||
|
await _send(ws, "flux_response", {
|
||||||
|
"requestId": request_id,
|
||||||
|
"state": "rendering",
|
||||||
|
"width": width, "height": height, "steps": steps,
|
||||||
|
"model": target_model_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
png = await runner.generate(prompt, width, height, steps, guidance, seed,
|
||||||
|
model_id=target_model_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("FLUX Render-Fehler")
|
||||||
|
await _send(ws, "flux_response", {"requestId": request_id, "error": str(e)[:200]})
|
||||||
|
if swap_needed:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
return
|
||||||
|
|
||||||
|
if swap_needed:
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_id,
|
||||||
|
loadSeconds=runner.last_load_seconds,
|
||||||
|
freshlyDownloaded=runner.last_load_was_download)
|
||||||
|
|
||||||
|
dt = time.time() - t0
|
||||||
|
b64 = base64.b64encode(png).decode("ascii")
|
||||||
|
logger.info("Render fertig: %dx%d, %d KB PNG, %.1fs (%s)",
|
||||||
|
width, height, len(png) // 1024, dt, runner.model_id)
|
||||||
|
|
||||||
|
await _send(ws, "flux_response", {
|
||||||
|
"requestId": request_id,
|
||||||
|
"state": "done",
|
||||||
|
"base64": b64,
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"steps": steps,
|
||||||
|
"guidance": guidance,
|
||||||
|
"seed": seed,
|
||||||
|
"model": runner.model_id,
|
||||||
|
"renderSeconds": round(dt, 2),
|
||||||
|
"sizeBytes": len(png),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Haupt-Loop ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def run_loop(runner: FluxRunner) -> None:
|
||||||
|
use_tls = RVS_TLS
|
||||||
|
retry_s = 2
|
||||||
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
scheme = "wss" if use_tls else "ws"
|
||||||
|
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
|
||||||
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Verbinde zu RVS: %s", masked)
|
||||||
|
# max_size 100 MB damit ein 4 MP PNG (~5-10 MB → ~13 MB base64)
|
||||||
|
# locker reinpasst. Mit dem RVS-Limit (100 MB) konsistent.
|
||||||
|
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
|
||||||
|
max_size=100 * 1024 * 1024) as ws:
|
||||||
|
logger.info("RVS verbunden")
|
||||||
|
retry_s = 2
|
||||||
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
async def _load_with_status():
|
||||||
|
"""Bei Connect KEIN Eager-Load — wir fragen erst die
|
||||||
|
Diagnostic-Config ab. Welches Modell tatsaechlich geladen
|
||||||
|
wird entscheidet sich entweder durch den config-Broadcast
|
||||||
|
(kommt direkt danach) oder durch den ersten flux_request.
|
||||||
|
Bis dahin gibt's keinen service_status, das Banner taucht
|
||||||
|
erst auf wenn wir wirklich was laden."""
|
||||||
|
try:
|
||||||
|
if runner.pipe is not None:
|
||||||
|
# Pipeline ueberlebt nur Container-Lifetime; hier
|
||||||
|
# also nur falls schon ein Modell aktiv ist (Reconnect).
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_id,
|
||||||
|
loadSeconds=runner.last_load_seconds)
|
||||||
|
logger.info("Initial: sende config_request an aria-bridge "
|
||||||
|
"(kein Eager-Load, warte auf Diagnostic-Wahl)")
|
||||||
|
await _send(ws, "config_request", {"service": "flux"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Initial-Setup crashed: %s", e)
|
||||||
|
try:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
asyncio.create_task(_load_with_status())
|
||||||
|
|
||||||
|
worker = asyncio.create_task(_flux_worker(ws, runner))
|
||||||
|
|
||||||
|
async def _apply_default_change(new_tag: str):
|
||||||
|
"""Wechselt den Default. Wenn ein anderes Modell als aktuell
|
||||||
|
aktiv gewuenscht ist, wird eager geladen — der naechste
|
||||||
|
Render ist dann ohne Swap-Delay."""
|
||||||
|
new_model_id = _tag_to_model_id(new_tag)
|
||||||
|
runner.default_model_id = new_model_id
|
||||||
|
if runner.model_id == new_model_id:
|
||||||
|
logger.info("[config] Default-Modell bleibt: %s", new_model_id)
|
||||||
|
return
|
||||||
|
will_download = not _is_model_cached(new_model_id)
|
||||||
|
logger.info("[config] Default-Modell wechselt: %s → %s (download=%s)",
|
||||||
|
runner.model_id or "(none)", new_model_id, will_download)
|
||||||
|
try:
|
||||||
|
await _broadcast_status(ws, "loading", model=new_model_id,
|
||||||
|
downloading=will_download)
|
||||||
|
await runner.ensure_loaded(new_model_id)
|
||||||
|
await _broadcast_status(ws, "ready",
|
||||||
|
model=runner.model_id,
|
||||||
|
loadSeconds=runner.last_load_seconds,
|
||||||
|
freshlyDownloaded=runner.last_load_was_download)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Modell-Swap fehlgeschlagen")
|
||||||
|
try:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for raw in ws:
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
mtype = msg.get("type", "")
|
||||||
|
payload = msg.get("payload", {}) or {}
|
||||||
|
|
||||||
|
if mtype == "flux_request":
|
||||||
|
await _flux_queue.put(payload)
|
||||||
|
elif mtype == "config":
|
||||||
|
# Diagnostic-Broadcast (oder aria-bridge nach Reconnect).
|
||||||
|
# HuggingFace-Token MUSS vor dem Modell-Swap gesetzt sein,
|
||||||
|
# weil FluxPipeline.from_pretrained den Token aus der env
|
||||||
|
# liest. Reihenfolge im selben Tick gewaehrleistet das.
|
||||||
|
if "huggingfaceToken" in payload:
|
||||||
|
tok = (payload.get("huggingfaceToken") or "").strip()
|
||||||
|
if tok:
|
||||||
|
os.environ["HF_TOKEN"] = tok
|
||||||
|
os.environ["HUGGING_FACE_HUB_TOKEN"] = tok
|
||||||
|
logger.info("[config] HF-Token gesetzt (len=%d)", len(tok))
|
||||||
|
else:
|
||||||
|
os.environ.pop("HF_TOKEN", None)
|
||||||
|
os.environ.pop("HUGGING_FACE_HUB_TOKEN", None)
|
||||||
|
logger.info("[config] HF-Token entfernt (leerer Wert)")
|
||||||
|
tag = (payload.get("fluxDefaultModel") or "").strip()
|
||||||
|
if tag:
|
||||||
|
asyncio.create_task(_apply_default_change(tag))
|
||||||
|
finally:
|
||||||
|
worker.cancel()
|
||||||
|
try:
|
||||||
|
await worker
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Verbindung verloren: %s", e)
|
||||||
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
|
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
||||||
|
use_tls = False
|
||||||
|
tls_fallback_tried = True
|
||||||
|
continue
|
||||||
|
await asyncio.sleep(min(retry_s, 30))
|
||||||
|
retry_s = min(retry_s * 2, 30)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
if not RVS_HOST:
|
||||||
|
logger.error("RVS_HOST nicht gesetzt — Abbruch")
|
||||||
|
sys.exit(1)
|
||||||
|
runner = FluxRunner()
|
||||||
|
await run_loop(runner)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# ════════════════════════════════════════════════
|
||||||
|
# ARIA FLUX-Bridge — Text-to-Image (GPU)
|
||||||
|
# Eigener Stack, weil FLUX auch auf einer anderen
|
||||||
|
# Maschine als f5tts/whisper laufen kann (z.B. 4090
|
||||||
|
# separat vom Gaming-PC). Verbindet sich selbst per
|
||||||
|
# WebSocket zum RVS und lauscht auf flux_request.
|
||||||
|
# ════════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - NVIDIA-GPU mit >= 12 GB VRAM (3060 reicht mit
|
||||||
|
# enable_model_cpu_offload). Bei < 12 GB:
|
||||||
|
# FLUX_OFFLOAD=sequential setzen, sonst OOM.
|
||||||
|
# - Docker mit NVIDIA Container Toolkit
|
||||||
|
# - HuggingFace-Token in .env (FLUX.1-dev ist gated)
|
||||||
|
# - .env mit RVS-Verbindungsdaten (gleiche wie xtts!)
|
||||||
|
#
|
||||||
|
# Start: docker compose up -d
|
||||||
|
# ════════════════════════════════════════════════
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ─── FLUX Bildgenerierung (GPU) ─────────
|
||||||
|
# Empfaengt flux_request via RVS, rendert PNG mit FLUX (12B Params)
|
||||||
|
# und broadcastet flux_response mit base64-PNG zurueck. aria-bridge speichert
|
||||||
|
# die Datei nach /shared/uploads/ und ARIA referenziert sie via [FILE:]-Marker.
|
||||||
|
#
|
||||||
|
# Modell-Wahl + HuggingFace-Token werden in ARIA Diagnostic eingestellt
|
||||||
|
# ("FLUX Bildgenerierung") und per RVS gepusht — hier nichts noetig.
|
||||||
|
flux-bridge:
|
||||||
|
build: .
|
||||||
|
container_name: aria-flux-bridge
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
- RVS_HOST=${RVS_HOST}
|
||||||
|
- RVS_PORT=${RVS_PORT:-443}
|
||||||
|
- RVS_TLS=${RVS_TLS:-true}
|
||||||
|
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
|
||||||
|
- RVS_TOKEN=${RVS_TOKEN}
|
||||||
|
# Hardware-Bootstrap (Diagnostic-Settings uebersteuern alles andere
|
||||||
|
# zur Laufzeit — diese envs sind nur Edge-Case-Fallbacks).
|
||||||
|
- FLUX_DEVICE=${FLUX_DEVICE:-cuda}
|
||||||
|
- FLUX_DTYPE=${FLUX_DTYPE:-bfloat16}
|
||||||
|
- FLUX_OFFLOAD=${FLUX_OFFLOAD:-model}
|
||||||
|
- FLUX_MAX_STEPS=${FLUX_MAX_STEPS:-50}
|
||||||
|
- FLUX_MAX_DIM=${FLUX_MAX_DIM:-1536}
|
||||||
|
volumes:
|
||||||
|
- ./hf-cache:/root/.cache/huggingface # Bind-Mount. FLUX.1-dev ~24 GB on disk!
|
||||||
|
# Wenn flux auf der gleichen Maschine
|
||||||
|
# wie xtts laeuft: ../xtts/hf-cache
|
||||||
|
# symlinken um den Cache zu teilen.
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
diffusers>=0.30.0
|
||||||
|
transformers>=4.43.0
|
||||||
|
accelerate>=0.33.0
|
||||||
|
sentencepiece>=0.2.0
|
||||||
|
protobuf>=4.25.0
|
||||||
|
pillow>=10.0.0
|
||||||
|
huggingface_hub>=0.24.0
|
||||||
|
websockets>=12.0
|
||||||
|
numpy>=1.24
|
||||||
@@ -297,6 +297,23 @@ Skills mit Tool-Use.
|
|||||||
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
||||||
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
||||||
|
|
||||||
|
### GPS-Trigger-Verbesserungen (entered_near + left_near + Timing-Fix)
|
||||||
|
|
||||||
|
- [x] **near() bei Auto-Vorbeifahrten verpasst — gefixt**: Background-Loop tickte alle 30s, Vorbeifahrt durch 300m-Radius bei 50-120 km/h dauert nur 18-43s → Tick konnte komplett dazwischen liegen. Fix: `TICK_SEC` 30 → 8 (Loop ist billig, Brain merkt das nicht). Plus event-getrieben: Bridge ruft nach jedem `location_update` ein POST `/triggers/check-now` im Brain → Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. Polling läuft parallel als Fallback für Watcher ohne GPS-Bezug
|
||||||
|
- [x] **near() Age-Schutz**: GPS-Daten älter als 5 Minuten (`NEAR_MAX_AGE_SEC=300`) gelten als veraltet → `near()` liefert False. Vorher hätte ein wochen-alter Wert die Funktion weiter als „in der Nähe" eingeordnet → Phantom-Fires wenn Tracking aus war
|
||||||
|
- [x] **Drei GPS-Modi statt einem**: `near()` bleibt = „solange drin". Neu: **`entered_near(lat, lon, r)`** feuert NUR beim Übergang außen→innen (Blitzer-Warner mit r=2000 = 2 km Vorwarnung, Ankunft mit r=100), **`left_near(lat, lon, r)`** feuert NUR beim Übergang innen→außen („Hast du am Parkplatz was vergessen?"). State-Tracking pro Trigger pro near-Aufruf (`near_states`-Dict im Manifest) — Background-Loop schreibt den letzten Auswertungswert immer zurück, damit beim nächsten Tick die Übergangs-Erkennung greift. ARIA's `trigger_watcher`-Tool-Description erklärt die drei Modi inkl. empfohlener Throttle-Werte (kurz für entered/left, lang für near)
|
||||||
|
|
||||||
|
### App-Memory-Editor + Crash-Reporting
|
||||||
|
|
||||||
|
- [x] **Bubble-Header dynamic** (created/updated/deleted): Die `🧠`-Bubble zeigt jetzt was passiert ist — "ARIA hat etwas gemerkt" / "Notiz geändert" / "Notiz gelöscht" (rot bei delete). Brain-Tools schicken `action`-Feld im memory_saved-Event mit
|
||||||
|
- [x] **Tap auf Memory-Bubble → Detail-Modal**: Komponente `MemoryDetailModal` zeigt alle Felder (Titel, Type, Category, Tags, voller Content, Anhang-Vorschau mit Thumbnails). Stift-Icon wechselt in Edit-Mode mit Form-Feldern + 📌 Pinned-Toggle. **Anhänge hoch-/runterladen + löschen** im Modal (DocumentPicker, multipart-Upload via RVS-Brain-Proxy). Memory komplett löschen mit Confirm
|
||||||
|
- [x] **Notizen-Inbox-Button (`🗂️`)** neben der Lupe in der Status-Leiste: Vollbild-Modal mit zwei Sections — „Aus diesem Chat" (kompakte Liste der Spezial-Bubbles aus dem aktuellen Verlauf, klickbar) + „Alle Memories aus der DB" mit dem `MemoryBrowser`. Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) werden im Chat-Stream gefiltert (statt unten zu kleben)
|
||||||
|
- [x] **Memory-Editor in App-Settings**: neue Sektion 🧠 „Gedächtnis" in den App-Einstellungen. Komplette CRUD-UI mit Wortlich-Suche, Type-Dropdown, Pinned/Cold-Filter, „+ Neu" anlegen. Selbe `MemoryBrowser`-Komponente wie in der Inbox
|
||||||
|
- [x] **RVS-Brain-Proxy als Fundament**: Bridge implementiert generischen `brain_request` / `brain_response`-Channel — die App kann beliebige Brain-HTTP-Endpoints via RVS adressieren (GET/POST/PATCH/DELETE, JSON+Base64-Body, base64-encoded Binär-Antworten). `services/brainApi.ts` als Promise-basierter Client mit Request-ID-Routing, Timeout, automatischem Listener-Setup
|
||||||
|
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary-Komponente fängt React-Render-Fehler, `installGlobalCrashReporter` haengt sich an `ErrorUtils.setGlobalHandler` + `HermesInternal.enablePromiseRejectionTracker`. Crashes wandern als `app_log`-Event durch RVS, Bridge schreibt JSONL in `/shared/logs/app.log`. Diagnostic-Server liefert GET `/api/app-log[?limit=N]` + POST `/api/app-log/clear`. **`tools/fetch-app-logs.sh`** holt die Logs auf die Dev-Maschine (über `ARIA_DIAG_URL` aus `.claude/aria-vm.env`), speichert in `.aria-debug/` (gitignored), zeigt Stack-Trace kompakt auf stdout
|
||||||
|
- [x] **`memory_search` + `memory_update` Tools**: ARIA kann die DB jetzt aktiv durchsuchen (Volltext/Semantic) und existierende Einträge per ID patchen statt fragmentierende neue anzulegen. Tool-Description sagt explizit „Memory ist Truth über Conversation-Window" — wenn der User korrigiert hat, gilt das was im Memory steht. Wichtig nach Diagnostic-Edits damit ARIA die neue Wahrheit sieht statt aus dem Window zu raten
|
||||||
|
- [x] **App-Bugfixes**: (a) URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt (`brainApi._qs()`). (b) Cache leer + Datei-Tap → Auto-Re-Download via file_request statt Toast-Sackgasse, plus State-Cleanup (uri/localUri auf undefined). (c) Memory-Liste in Settings scrollt jetzt (nestedScrollEnabled auf FlatList + äußere ScrollView). (d) Modal-im-Modal auf Android gefixt — MemoryBrowser nimmt optionalen `onOpenMemory`-Callback, kein verschachteltes DetailModal mehr. (e) Alert.prompt (iOS-only) durch eigenes Text-Input-Modal ersetzt fuer „Neue Memory anlegen"
|
||||||
|
|
||||||
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
||||||
|
|
||||||
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
||||||
@@ -324,17 +341,66 @@ Skills mit Tool-Use.
|
|||||||
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
|
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
|
||||||
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
|
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
|
||||||
|
|
||||||
|
### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake
|
||||||
|
|
||||||
|
- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep
|
||||||
|
- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`)
|
||||||
|
- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt
|
||||||
|
- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart
|
||||||
|
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
|
||||||
|
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
|
||||||
|
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
|
||||||
|
- [x] **chat_backup ts war Container-Uptime statt UNIX-ms**: `_append_chat_backup` nutzte `asyncio.get_event_loop().time()` (Monotonic, bei jedem Restart wieder 0) statt `time.time()`. Folge: Server-Bubbles mit ts wie 394M (6 min Uptime) wurden in der App-History neben App-side Bubbles mit Date.now() (1.778e12) sortiert — Hello-Kitty-Konversation von gestern landete chronologisch nach heutigen Karten-Routen, neue Nachrichten verschwanden unter dem 500er-Cap. Plus: Doppelpost-Schutz griff nicht weil das 5-Min-ts-Fenster bei 1.7 Bio ms Diff nie zutraf. Fix: Bridge schreibt jetzt UNIX-ms, Migration-Script `tools/migrate_chat_backup_ts.py` repariert vorhandene jsonl (284/299 ts umgeschrieben auf der VM, Datei-Reihenfolge bleibt). App-Merge dedupt zusaetzlich per blossem Text-Match (ohne ts-Diff) — schuetzt auch gegen vorhandene lokale Duplikate
|
||||||
|
- [x] **User-Bubble ⏳→failed bei langsamen ARIA-Antworten**: ACK-Timer (30 s × 3 Retries) lief durch obwohl Brain laengst arbeitete — wenn `chat_ack` aus irgendwelchen Gruenden nicht durchkam (RVS-Frame verloren etc.), wurde die Bubble nach 90 s auf failed gesetzt obwohl die Antwort gleich danach kam. Fix: jedes `agent_activity != idle`-Event ist impliziter ACK — Brain wuerde nicht arbeiten wenn es die Nachricht nicht haette. Beim ersten non-idle Event werden alle laufenden ACK-Timer gecanceled und sending-Bubbles auf 'sent' gesetzt. ACK_TIMEOUT_MS zusaetzlich von 30 s auf 60 s als Backup
|
||||||
|
- [x] **Gedanken-Stream Modal scrollte nicht**: innerer `TouchableOpacity` (eigentlich nur fuer close-on-tap-outside-Schutz) hat alle Touch-Events konsumiert. Fix: durch `View` mit `onStartShouldSetResponder={true}` + `onResponderTerminationRequest={false}` ersetzt — blockt Tap-Propagation ohne Scrolls der Children zu verschlucken
|
||||||
|
|
||||||
|
### Brain-Hang: Multi-Tool-Timeouts + RVS-Block + Skill-Aggressivitaet
|
||||||
|
|
||||||
|
- [x] **Skill-Erstellung aggressiver als gewollt**: Prompt sagte „Harte Regel — IMMER Skill anlegen wenn pip-Library noetig". ARIA hat das wortwoertlich genommen und bei einer simplen pdf-extract-Frage sofort `skill_create` aufgerufen → Brain 12 Min blockiert (venv 2 min + pip install 10 min Timeout in `skills.py`). App zeigt „ARIA denkt", Bridge emitted nach 5 Min Timeout idle, User ohne Antwort. Fix in `prompts.py`: „Goldene Regel: NIE ungefragt Skills anlegen" + nur bei expliziter Anfrage („mach daraus einen Skill") und auch dann nur wenn die 4 Kriterien (wiederkehrend / nicht-trivial / parametrisierbar / wiederverwendbar) zutreffen. Greift auf der VM nach `docker compose restart aria-brain` ohne Re-Build
|
||||||
|
- [x] **Brain-Timeouts 5 Min → 20 Min**: drei verkettete 5-Min-Timeouts (Bridge `urlopen`, Brain `proxy_client`, Proxy `DEFAULT_TIMEOUT` im claude-max-api-proxy npm-Modul) feuerten exakt gleichzeitig. Live in den Logs nachvollzogen: ein Proxy-Call brauchte 4m51s und wurde von der Bridge auf den Sekundenbruchteil genau gekappt. Aufgabenstellungen wie Karten-Rekonstruktion mit 10+ curl-Calls oder PDF-Verarbeitung brauchen aber locker 8–15 Min. Fix: alle drei Timeouts auf 1200 s, plus dritter sed-Patch im docker-compose proxy-Service (`DEFAULT_TIMEOUT = 300000 → 1200000`). App-Stuck-Watchdog auf 1260 s (21 Min, knapp drueber)
|
||||||
|
- [x] **RVS-Block waehrend Brain-Call** (mobil.hacker-net.de:444 droppt nach 4 Min idle): `async for raw_message in ws: await _handle_rvs_message(...)` — das await blockierte den recv-Loop solange `send_to_core` lief. Die websockets-Lib beantwortete Pings im Hintergrund, aber der RVS-Server zaehlt nur echte App-Frames und droppt sonst die Verbindung. Symptom: App+Diagnostic zeigten „abgebrochen" obwohl Brain noch arbeitete. Fix: `send_to_core` als `asyncio.create_task` statt `await` — RVS-recv-Loop bleibt frei, neue Messages werden weiter verarbeitet, Verbindung bleibt lebendig
|
||||||
|
|
||||||
|
### Gedanken-Stream + Live-Tool-Events
|
||||||
|
|
||||||
|
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
|
||||||
|
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
|
||||||
|
|
||||||
|
### Such-Sprung-Praezision + Such-Reihenfolge
|
||||||
|
|
||||||
|
- [x] **Such-Sprung kalt nach App-Start**: scrollToIndex landete bei langen Listen weit daneben (Cessna-Treffer → Sprung zur Oberhausen-Bubble 15 Stellen daneben). `info.averageItemLength` aus `onScrollToIndexFailed` basierte auf den ersten ~10 gerenderten Items — bei sehr unterschiedlichen Bubble-Hoehen (Voice ~70 px, lange ARIA-Antworten 400+ px) eine grottige Schaetzung. Fix: `itemHeights`-Ref-Map wird per `onLayout` in `renderMessage` gefuettert; Pre-Scroll summiert echte gemessene Hoehen (Fallback `AVG_BUBBLE_HEIGHT=150` fuer noch nicht gerenderte). Plus `initialNumToRender: 30` (Default 10) und `windowSize: 41` (Default 21) → mehr Items beim Mount gemessen
|
||||||
|
- [x] **Such-Scroll Endlos-Loop (Wiederkehr)**: `onScrollToIndexFailed` retried unbegrenzt — jeder failed Retry rief den Handler erneut auf → neuer Timer → fail → loop. Plus: `setMessages` im `agent_activity`-Handler rief `prev.map()` auch wenn nichts zu aendern war → neues Array bei jedem Tool-Event → FlatList-Layouts invalidiert mitten in der Scroll-Sequenz. Fix: hartes `MAX_SCROLL_RETRIES = 3` plus `prev.some()`-Check vor `.map()` damit reference-stable bei No-Op
|
||||||
|
- [x] **Such-Treffer in Spezial-Bubbles**: `searchMatchIds` suchte in `messages` (alle Bubbles inkl. Memory/Skill/Trigger), aber gescrollt wird in `invertedMessages` die diese filtert → `findIndex=-1` → kein Scroll, alter Pre-Scroll-Stand bleibt sichtbar. Fix: `searchMatchIds` aus `chatVisibleMessages`. Memory-Inhalte sind weiterhin ueber die 🗂️-Inbox erreichbar
|
||||||
|
- [x] **Such-Reihenfolge: neueste zuerst** (WhatsApp/Telegram-analog): User ist visuell unten im Chat, der erste Treffer ist meist schon im Viewport ohne weiten Pre-Scroll. „Naechster" geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit FlatList beim ersten Versuch Render-Zeit hat
|
||||||
|
|
||||||
|
### Misc App-Polish
|
||||||
|
|
||||||
|
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
|
||||||
|
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
|
||||||
|
|
||||||
|
### Brain-Timeouts + Subprocess-Cleanup
|
||||||
|
|
||||||
|
- [x] **Brain-Timeout nach exakt 20min trotz aktiver ARIA**: `httpx.Client` im `proxy_client.py` hatte einen 1200s-Read-Timeout — der gleiche Wert den wir Tage zuvor am Proxy auf 24h hochgezogen hatten, aber im Brain uebersehen. Bei langen Pentests timed Brain raus obwohl der Proxy-Subprocess noch fleissig Events emittierte. Fix: `PROXY_TIMEOUT_SEC=86400` Env in der Compose, plus split-Timeouts in `httpx.Timeout(connect=10, read=86400, write=30, pool=10)` — toter Proxy wird in 10s erkannt, lange ARIA-Sessions duerfen 24h laufen
|
||||||
|
- [x] **Verwaiste Claude-Subprocesses nach Brain-Disconnect**: `handleNonStreamingResponse` in `routes.js` hatte keinen `res.on("close")` (nur der Streaming-Branch). Wenn Brain die Verbindung gekappt hat (z.B. nach Timeout), lief der Claude-Subprocess weiter ohne dass noch jemand lauschte — Ressourcen-Leak. Fix: `res.on("close")` mit `isComplete`-Flag, Subprocess wird sofort gekillt bei Client-Disconnect
|
||||||
|
- [x] **Conversation-Inkonsistenz bei Brain-Exception**: `agent.chat()` fuegte den User-Turn ein BEVOR der Proxy-Call lief — bei Exception blieb der User-Turn ohne Assistant-Pair stehen, naechster Brain-Call sah `user → user` als letzte zwei Turns und konnte mit Tool-Calls fehlschlagen. Fix: try/except um den Tool-Loop, bei Exception wird ein Error-Marker (`[Fehler: ...]`) als Assistant-Turn geschrieben — Conversation bleibt konsistent
|
||||||
|
|
||||||
|
### OAuth-Pipeline (Spotify / Google / GitHub / Strava / Microsoft)
|
||||||
|
|
||||||
|
- [x] **Externe OAuth2-Provider per RVS-Callback**: ARIA brauchte Tokens fuer Spotify-Skill — bisher `redirect_uri=http://localhost:...` was vom Handy aus nicht erreichbar war, Stefan musste den Code manuell aus der URL kopieren (fragil, OAuth-Codes sind ~10min gueltig). Loesung: RVS-Server hat jetzt einen HTTP-Listener (selber Port wie WebSocket, hybrid via `http.createServer` + `wss.handleUpgrade`). Provider redirected an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet `oauth_callback`-Message → aria-bridge forwarded an Brain → Brain matched `state` (CSRF-Schutz), tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json` (file-mode 0600). Token-Refresh laeuft automatisch wenn <60s Restzeit
|
||||||
|
- [x] **Brain-Tools fuer ARIA**: `oauth_authorize(service, scopes?)` baut Auth-URL + speichert pending state, `oauth_get_token(service)` liefert aktuelles access_token (refresh wenn noetig), `oauth_revoke(service)` loescht. Skills nutzen diese statt selber Auth-Flow zu machen
|
||||||
|
- [x] **Generische Provider-Configs**: `DEFAULT_PROVIDERS` in `oauth.py` deckt Spotify, Google, GitHub, Strava, Microsoft mit ihren Quirks ab (Basic-Auth vs Body-Auth, Accept-Header fuer GitHub, `access_type=offline` fuer Google, etc.). Custom-Provider via `oauth_apps.json` moeglich
|
||||||
|
- [x] **Diagnostic-UI**: Einstellungen → OAuth-Apps. Pro Service Karte mit Status (verbunden/konfiguriert/leer), `client_id` + `client_secret` (Passwort-Toggle), Speichern + Autorisieren-Buttons. Autorisieren oeffnet Provider-Auth in neuem Tab; nach 8s Auto-Refresh
|
||||||
|
- [x] **Schoene Browser-Antwort vom RVS**: nach Callback bekommt der User eine Dark-Mode-HTML-Seite (✅ "OAuth erfolgreich, du kannst Tab schliessen — ARIA hat den Zugang erhalten") mit 4s Auto-Close — kein nackter JSON-Response
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
### App Features
|
### App Features
|
||||||
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
|
|
||||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||||
|
|
||||||
### Architektur
|
### Architektur
|
||||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
|
||||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||||
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||||
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
|
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
|
||||||
- [ ] Heartbeat (periodische Selbst-Checks)
|
- [ ] Heartbeat (periodische Selbst-Checks)
|
||||||
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
|
||||||
|
- [ ] **Subprocess-Resume nach Kill/Timeout (Variante A — halb-automatisch)**: bei Idle-Timeout oder Brain-Disconnect ist die ARIA-Session weg (in-memory state des Claude-Code-Subprozesses, alle Tool-Outputs, Files-Reads). Stefan muss heute manuell *"weitermachen"* sagen, ARIA improvisiert aus dem Conversation-Window was sie noch weiss. Variante A: agent_stream-Events zusaetzlich in einer JSONL persistieren, beim naechsten Brain-Call die letzten N Events als „Resume-Context" in den System-Prompt einbauen — ARIA weiss dann konkret welche Tool-Calls zuletzt liefen und kann sauber fortsetzen. Aufwand ~1-2h. Nur angehen wenn die 24h-Timeouts (Commit 0887674) wirklich nochmal triggern
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
/**
|
||||||
|
* ARIA-patched API Route Handlers
|
||||||
|
*
|
||||||
|
* Erweiterung der npm-Version von claude-max-api-proxy:
|
||||||
|
* - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read,
|
||||||
|
* Edit, Grep, …) wird ein HTTP-POST an die Bridge gefeuert
|
||||||
|
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
|
||||||
|
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
|
||||||
|
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
|
||||||
|
* - Voller Live-Stream (assistant_text, tool_use mit input, tool_result)
|
||||||
|
* geht an ARIA_STREAM_HOOK_URL → Bridge → RVS `agent_stream` → Diagnostic
|
||||||
|
* "ARIA Live"-View (TeamViewer-mäßiger Mirror der Claude-Code-Session).
|
||||||
|
* - Subprocess-Tracking + POST /v1/cancel-all fuer Not-Aus (Hard-Kill).
|
||||||
|
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
|
||||||
|
* der Brain-Call NICHT ab.
|
||||||
|
*
|
||||||
|
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
|
||||||
|
* (siehe docker-compose.yml proxy-Block).
|
||||||
|
*/
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import http from "http";
|
||||||
|
import { ClaudeSubprocess } from "../subprocess/manager.js";
|
||||||
|
import { openaiToCli } from "../adapter/openai-to-cli.js";
|
||||||
|
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
|
||||||
|
|
||||||
|
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|
||||||
|
|| "http://aria-bridge:8090/internal/agent-activity";
|
||||||
|
const STREAM_HOOK_URL = process.env.ARIA_STREAM_HOOK_URL
|
||||||
|
|| "http://aria-bridge:8090/internal/agent-stream";
|
||||||
|
|
||||||
|
// Tool-Output kann sehr lang werden (git log -p, find /). Wir truncaten
|
||||||
|
// hart auf 4 KB pro Event — der User sieht weiterhin den Anfang und einen
|
||||||
|
// "...(N bytes truncated)" Hinweis. Vollstaendiger Output bleibt im Brain
|
||||||
|
// und wird normal verarbeitet, das hier ist NUR fuer den Live-Mirror.
|
||||||
|
const TOOL_RESULT_MAX_CHARS = 4096;
|
||||||
|
const TOOL_INPUT_MAX_CHARS = 2048;
|
||||||
|
|
||||||
|
// Idle-Timeout: Subprocess wird gekillt wenn ueber IDLE_TIMEOUT_MS keine
|
||||||
|
// Aktivitaet (message/content_delta) ankommt. Loest das alte Hard-Timeout-
|
||||||
|
// Problem fuer lange Agent-Sessions (Pentests etc.) — ARIA darf ewig
|
||||||
|
// arbeiten solange sie regelmaessig was emittiert, aber wenn der Subprocess
|
||||||
|
// hartnaeckig haengt, schlaegt der Watchdog trotzdem zu.
|
||||||
|
// Default 20min Idle. Override via env ARIA_IDLE_TIMEOUT_MS.
|
||||||
|
// 0 = deaktiviert (nicht empfohlen).
|
||||||
|
const IDLE_TIMEOUT_MS = parseInt(process.env.ARIA_IDLE_TIMEOUT_MS || "1200000", 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
|
||||||
|
* nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
|
||||||
|
*/
|
||||||
|
function _postJson(url, body) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const data = JSON.stringify(body);
|
||||||
|
const req = http.request({
|
||||||
|
method: "POST",
|
||||||
|
hostname: u.hostname,
|
||||||
|
port: u.port || 80,
|
||||||
|
path: u.pathname,
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
|
||||||
|
timeout: 2000,
|
||||||
|
}, (res) => { res.resume(); });
|
||||||
|
req.on("error", () => {});
|
||||||
|
req.on("timeout", () => req.destroy());
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
|
} catch (_) { /* niemals weiterwerfen */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
|
||||||
|
*/
|
||||||
|
function _emitToolEvent(toolName) {
|
||||||
|
if (!toolName) return;
|
||||||
|
_postJson(TOOL_HOOK_URL, { tool: String(toolName) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pusht ein Stream-Event an die Bridge (neuer "ARIA Live"-Pfad).
|
||||||
|
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
|
||||||
|
*/
|
||||||
|
function _emitStreamEvent(requestId, kind, fields) {
|
||||||
|
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _truncate(str, max) {
|
||||||
|
if (typeof str !== "string") str = String(str ?? "");
|
||||||
|
if (str.length <= max) return { text: str, truncatedBytes: 0 };
|
||||||
|
return { text: str.slice(0, max), truncatedBytes: str.length - max };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subprocess-Tracking fuer Not-Aus ──────────────────────────
|
||||||
|
// requestId → ClaudeSubprocess. Eintraege werden beim close/result-Event
|
||||||
|
// wieder entfernt. /v1/cancel-all iteriert und ruft .kill() auf jeden.
|
||||||
|
const _activeSubprocesses = new Map();
|
||||||
|
function _trackSubprocess(requestId, subprocess) {
|
||||||
|
_activeSubprocesses.set(requestId, subprocess);
|
||||||
|
const cleanup = () => _activeSubprocesses.delete(requestId);
|
||||||
|
subprocess.on("close", cleanup);
|
||||||
|
subprocess.on("error", cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idle-Watchdog: killt den Subprocess wenn ueber IDLE_TIMEOUT_MS hinweg
|
||||||
|
* keine message/content_delta Events ankommen. Wird beim Start gesetzt,
|
||||||
|
* bei jedem Event reset, bei close/error/result gestoppt.
|
||||||
|
*
|
||||||
|
* Stream-Event 'end' wird durch den normalen close-Listener im Handler
|
||||||
|
* gefeuert — wir muessen hier nichts extra emittieren.
|
||||||
|
*/
|
||||||
|
function _attachIdleWatchdog(subprocess, requestId) {
|
||||||
|
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return; // disabled
|
||||||
|
let timer = null;
|
||||||
|
let killed = false;
|
||||||
|
|
||||||
|
function _kill() {
|
||||||
|
if (killed) return;
|
||||||
|
killed = true;
|
||||||
|
const mins = Math.round(IDLE_TIMEOUT_MS / 60000);
|
||||||
|
console.warn(`[aria-idle] killing subprocess ${requestId} after ${mins}min idle`);
|
||||||
|
try { subprocess.kill(); } catch (_) {}
|
||||||
|
_emitStreamEvent(requestId, "end", { reason: "idle_timeout", idleMs: IDLE_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _reset() {
|
||||||
|
if (killed) return;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(_kill, IDLE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stop() {
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial-Timer setzen
|
||||||
|
_reset();
|
||||||
|
|
||||||
|
// Jedes Event vom Subprozess zaehlt als Lebenszeichen
|
||||||
|
subprocess.on("message", _reset);
|
||||||
|
subprocess.on("content_delta", _reset);
|
||||||
|
// Result/close/error → endgueltig stop
|
||||||
|
subprocess.on("result", _stop);
|
||||||
|
subprocess.on("close", _stop);
|
||||||
|
subprocess.on("error", _stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hookt assistant + user Events und pusht beides an Bridge:
|
||||||
|
* - Alt-API: nur Tool-Namen an /internal/agent-activity (Gedanken-Stream)
|
||||||
|
* - Neu-API: voller Stream (text/tool_use/tool_result) an /internal/agent-stream
|
||||||
|
*/
|
||||||
|
function _attachToolHook(subprocess, requestId) {
|
||||||
|
subprocess.on("assistant", (message) => {
|
||||||
|
try {
|
||||||
|
const blocks = message?.message?.content || [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (!b) continue;
|
||||||
|
if (b.type === "tool_use") {
|
||||||
|
if (b.name) _emitToolEvent(b.name);
|
||||||
|
const inputStr = b.input ? JSON.stringify(b.input) : "";
|
||||||
|
const inp = _truncate(inputStr, TOOL_INPUT_MAX_CHARS);
|
||||||
|
_emitStreamEvent(requestId, "tool_use", {
|
||||||
|
id: b.id || null,
|
||||||
|
name: b.name || "",
|
||||||
|
input: inp.text,
|
||||||
|
inputTruncatedBytes: inp.truncatedBytes,
|
||||||
|
});
|
||||||
|
} else if (b.type === "text" && b.text) {
|
||||||
|
_emitStreamEvent(requestId, "text", { text: b.text });
|
||||||
|
} else if (b.type === "thinking" && b.thinking) {
|
||||||
|
// Wenn das Modell Extended Thinking emittiert — selten in
|
||||||
|
// Claude Code CLI, aber moeglich. Markieren wir extra.
|
||||||
|
_emitStreamEvent(requestId, "thinking", { text: b.thinking });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* fail-open */ }
|
||||||
|
});
|
||||||
|
// tool_result Blocks kommen in user-Messages — die werden vom
|
||||||
|
// subprocess-Manager NICHT als 'user'-Event emittiert (gibt's nicht),
|
||||||
|
// sondern nur ueber das generische 'message'-Event mit type:'user'.
|
||||||
|
// 'message' feuert auch fuer assistant/result — wir filtern auf user
|
||||||
|
// damit wir nicht doppelt rendern (assistant geht ueber den eigenen
|
||||||
|
// assistant-Handler oben).
|
||||||
|
subprocess.on("message", (message) => {
|
||||||
|
try {
|
||||||
|
if (message?.type !== "user") return;
|
||||||
|
const blocks = message?.message?.content || [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b && b.type === "tool_result") {
|
||||||
|
let content = "";
|
||||||
|
if (typeof b.content === "string") content = b.content;
|
||||||
|
else if (Array.isArray(b.content)) {
|
||||||
|
content = b.content.map(c => (c && c.type === "text" && c.text) ? c.text : "").join("");
|
||||||
|
}
|
||||||
|
const out = _truncate(content, TOOL_RESULT_MAX_CHARS);
|
||||||
|
_emitStreamEvent(requestId, "tool_result", {
|
||||||
|
id: b.tool_use_id || null,
|
||||||
|
content: out.text,
|
||||||
|
truncatedBytes: out.truncatedBytes,
|
||||||
|
isError: b.is_error === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* fail-open */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle POST /v1/chat/completions
|
||||||
|
*
|
||||||
|
* Main endpoint for chat requests, supports both streaming and non-streaming
|
||||||
|
*/
|
||||||
|
export async function handleChatCompletions(req, res) {
|
||||||
|
const requestId = uuidv4().replace(/-/g, "").slice(0, 24);
|
||||||
|
const body = req.body;
|
||||||
|
const stream = body.stream === true;
|
||||||
|
try {
|
||||||
|
// Validate request
|
||||||
|
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: "messages is required and must be a non-empty array",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
code: "invalid_messages",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert to CLI input format
|
||||||
|
const cliInput = openaiToCli(body);
|
||||||
|
const subprocess = new ClaudeSubprocess();
|
||||||
|
// ARIA-Patch: Tool-Use-Events + voller Live-Stream an die Bridge.
|
||||||
|
// Plus: Subprocess fuer Not-Aus tracken (Hard-Kill via /v1/cancel-all).
|
||||||
|
// Plus: Idle-Watchdog — Subprocess darf ewig laufen solange Events
|
||||||
|
// kommen, wird aber gekillt nach IDLE_TIMEOUT_MS Inaktivitaet.
|
||||||
|
_attachToolHook(subprocess, requestId);
|
||||||
|
_trackSubprocess(requestId, subprocess);
|
||||||
|
_attachIdleWatchdog(subprocess, requestId);
|
||||||
|
_emitStreamEvent(requestId, "start", { model: body.model || null });
|
||||||
|
subprocess.on("result", () => _emitStreamEvent(requestId, "end", { reason: "result" }));
|
||||||
|
subprocess.on("close", (code) => _emitStreamEvent(requestId, "end", { reason: "close", code }));
|
||||||
|
subprocess.on("error", (err) => _emitStreamEvent(requestId, "end", { reason: "error", error: String(err?.message || err) }));
|
||||||
|
if (stream) {
|
||||||
|
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await handleNonStreamingResponse(res, subprocess, cliInput, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("[handleChatCompletions] Error:", message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle streaming response (SSE)
|
||||||
|
*
|
||||||
|
* IMPORTANT: The Express req.on("close") event fires when the request body
|
||||||
|
* is fully received, NOT when the client disconnects. For SSE connections,
|
||||||
|
* we use res.on("close") to detect actual client disconnection.
|
||||||
|
*/
|
||||||
|
async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) {
|
||||||
|
// Set SSE headers
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Request-Id", requestId);
|
||||||
|
// CRITICAL: Flush headers immediately to establish SSE connection
|
||||||
|
// Without this, headers are buffered and client times out waiting
|
||||||
|
res.flushHeaders();
|
||||||
|
// Send initial comment to confirm connection is alive
|
||||||
|
res.write(":ok\n\n");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let isFirst = true;
|
||||||
|
let lastModel = "claude-sonnet-4";
|
||||||
|
let isComplete = false;
|
||||||
|
// Handle actual client disconnect (response stream closed)
|
||||||
|
res.on("close", () => {
|
||||||
|
if (!isComplete) {
|
||||||
|
// Client disconnected before response completed - kill subprocess
|
||||||
|
subprocess.kill();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Handle streaming content deltas
|
||||||
|
subprocess.on("content_delta", (event) => {
|
||||||
|
const text = event.event.delta?.text || "";
|
||||||
|
if (text && !res.writableEnded) {
|
||||||
|
const chunk = {
|
||||||
|
id: `chatcmpl-${requestId}`,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: lastModel,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
role: isFirst ? "assistant" : undefined,
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
finish_reason: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Handle final assistant message (for model name)
|
||||||
|
subprocess.on("assistant", (message) => {
|
||||||
|
lastModel = message.message.model;
|
||||||
|
});
|
||||||
|
subprocess.on("result", (_result) => {
|
||||||
|
isComplete = true;
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
// Send final done chunk with finish_reason
|
||||||
|
const doneChunk = createDoneChunk(requestId, lastModel);
|
||||||
|
res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("error", (error) => {
|
||||||
|
console.error("[Streaming] Error:", error.message);
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
error: { message: error.message, type: "server_error", code: null },
|
||||||
|
})}\n\n`);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("close", (code) => {
|
||||||
|
// Subprocess exited - ensure response is closed
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
if (code !== 0 && !isComplete) {
|
||||||
|
// Abnormal exit without result - send error
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
error: { message: `Process exited with code ${code}`, type: "server_error", code: null },
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Start the subprocess
|
||||||
|
subprocess.start(cliInput.prompt, {
|
||||||
|
model: cliInput.model,
|
||||||
|
sessionId: cliInput.sessionId,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("[Streaming] Subprocess start error:", err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle non-streaming response
|
||||||
|
*/
|
||||||
|
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let finalResult = null;
|
||||||
|
let isComplete = false;
|
||||||
|
// Client-Disconnect-Handler — wenn Brain die HTTP-Verbindung kappt
|
||||||
|
// (z.B. nach Read-Timeout), den noch laufenden Subprocess killen.
|
||||||
|
// Im Streaming-Branch existiert das schon; non-streaming hatte's
|
||||||
|
// bisher nicht → Subprozess lief verwaist weiter, Ressourcen-Leak.
|
||||||
|
res.on("close", () => {
|
||||||
|
if (!isComplete) {
|
||||||
|
console.warn("[NonStreaming] Client disconnected before result — killing subprocess", requestId);
|
||||||
|
try { subprocess.kill(); } catch (_) {}
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("result", (result) => {
|
||||||
|
finalResult = result;
|
||||||
|
});
|
||||||
|
subprocess.on("error", (error) => {
|
||||||
|
console.error("[NonStreaming] Error:", error.message);
|
||||||
|
isComplete = true;
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("close", (code) => {
|
||||||
|
isComplete = true;
|
||||||
|
if (res.writableEnded) {
|
||||||
|
// Client ist eh schon weg — nichts mehr zu senden.
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (finalResult) {
|
||||||
|
res.json(cliResultToOpenai(finalResult, requestId));
|
||||||
|
}
|
||||||
|
else if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Claude CLI exited with code ${code} without response`,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Start the subprocess
|
||||||
|
subprocess
|
||||||
|
.start(cliInput.prompt, {
|
||||||
|
model: cliInput.model,
|
||||||
|
sessionId: cliInput.sessionId,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle GET /v1/models
|
||||||
|
*
|
||||||
|
* Returns available models
|
||||||
|
*/
|
||||||
|
export function handleModels(_req, res) {
|
||||||
|
res.json({
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "claude-opus-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-haiku-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle GET /health
|
||||||
|
*
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
export function handleHealth(_req, res) {
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
provider: "claude-code-cli",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not-Aus Side-Channel ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// claude-max-api-proxy steuert seine eigene Route-Registrierung — wir
|
||||||
|
// koennen da nicht reinpatchen ohne sed-Operationen am npm-Paket. Saubrer:
|
||||||
|
// ein dedizierter kleiner HTTP-Listener nur fuer den Not-Aus, auf einem
|
||||||
|
// internen Port im aria-net. Bridge ruft den, killt alle aktiven Claude-
|
||||||
|
// Subprocesses. App + Diagnostic sehen den Stream sofort enden.
|
||||||
|
const INTERNAL_PORT = parseInt(process.env.ARIA_PROXY_INTERNAL_PORT || "3457", 10);
|
||||||
|
const INTERNAL_HOST = "0.0.0.0"; // im aria-net erreichbar, nicht nach extern exposed
|
||||||
|
|
||||||
|
function _cancelAll() {
|
||||||
|
const ids = Array.from(_activeSubprocesses.keys());
|
||||||
|
let killed = 0;
|
||||||
|
for (const [id, subp] of _activeSubprocesses) {
|
||||||
|
try {
|
||||||
|
subp.kill();
|
||||||
|
killed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[aria-not-aus] kill failed for", id, e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_activeSubprocesses.clear();
|
||||||
|
return { killed, requestIds: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const internalServer = http.createServer((req, res) => {
|
||||||
|
if (req.method === "POST" && req.url === "/cancel-all") {
|
||||||
|
const result = _cancelAll();
|
||||||
|
console.warn("[aria-not-aus] /cancel-all — killed", result.killed, "subprocess(es)");
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, ...result }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "GET" && req.url === "/health") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, active: _activeSubprocesses.size }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end();
|
||||||
|
});
|
||||||
|
internalServer.on("error", (err) => {
|
||||||
|
console.error("[aria-not-aus] internal listener error:", err.message);
|
||||||
|
});
|
||||||
|
internalServer.listen(INTERNAL_PORT, INTERNAL_HOST, () => {
|
||||||
|
console.log("[aria-not-aus] internal listener on", INTERNAL_HOST + ":" + INTERNAL_PORT);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[aria-not-aus] startup failed:", e?.message);
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=routes.js.map
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# ARIA RVS — Server-seitige Config
|
||||||
|
# Kopieren nach .env und Werte eintragen.
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# Oeffentlich erreichbarer DNS-Name dieses Servers. Caddy holt darauf ein
|
||||||
|
# Let's Encrypt-Zertifikat (HTTP-01 Challenge ueber Port 80) und routet
|
||||||
|
# WebSocket + HTTP weiter an den RVS-Container.
|
||||||
|
#
|
||||||
|
# WICHTIG:
|
||||||
|
# - Die Domain muss per DNS-A-Record/AAAA auf diese Maschine zeigen
|
||||||
|
# - Port 80 + 443 muessen vom Internet aus erreichbar sein
|
||||||
|
# - Kein anderer Reverse-Proxy davor (sonst Cert-Konflikt)
|
||||||
|
PUBLIC_URL=rvs.example.de
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Docker-Compose Konfiguration mit echtem Domain-Namen
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Caddy persistent state (Zertifikate, ACME-Account)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# APK-Verzeichnis bleibt — wird ueber release.sh befuellt + commited als latest.apk
|
||||||
|
# (siehe Hauptverzeichnis README)
|
||||||
+45
-2
@@ -1,10 +1,53 @@
|
|||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# ARIA RVS Stack — WebSocket Relay + OAuth Callback HTTP
|
||||||
|
# Caddy davor terminiert TLS via Let's Encrypt (HTTP-01
|
||||||
|
# Challenge ueber Port 80). OAuth-Provider wie Spotify
|
||||||
|
# verlangen HTTPS fuer non-localhost Redirect-URIs.
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - Port 80 + 443 frei (kein anderer Reverse-Proxy davor)
|
||||||
|
# - Domain (PUBLIC_URL) zeigt per DNS auf diese Maschine
|
||||||
|
# - .env mit PUBLIC_URL gesetzt
|
||||||
|
#
|
||||||
|
# Start: docker compose up -d
|
||||||
|
# Wenn Du einen eigenen TLS-Terminator nutzt (z.B. nginx,
|
||||||
|
# externer Caddy): caddy-service auskommentieren und
|
||||||
|
# rvs-Container den ports-Block geben (3000 → public Port).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
rvs:
|
rvs:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
|
||||||
- "${RVS_PORT:-443}:3000"
|
|
||||||
restart: always
|
restart: always
|
||||||
|
# KEIN ports-Block — Caddy ist davor, RVS nur intern
|
||||||
|
# via aria-rvs-net erreichbar. Wenn Du Caddy nicht nutzt,
|
||||||
|
# diesen ports-Block reaktivieren: ports: ["${RVS_PORT:-443}:3000"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
||||||
environment:
|
environment:
|
||||||
- MAX_SESSIONS=10
|
- MAX_SESSIONS=10
|
||||||
|
networks:
|
||||||
|
- aria-rvs-net
|
||||||
|
|
||||||
|
# TLS-Terminator + Let's Encrypt. Holt automatisch ein Zertifikat
|
||||||
|
# fuer ${PUBLIC_URL} (HTTP-01 Challenge ueber Port 80). WebSocket-
|
||||||
|
# Upgrades und HTTP-Routes (OAuth-Callback) werden im reverse-proxy
|
||||||
|
# Modus automatisch durchgereicht. ACME-Cache liegt in ./data/caddy/
|
||||||
|
# damit Restart nicht jedes Mal ein neues Cert holt (Rate-Limit!).
|
||||||
|
caddy:
|
||||||
|
image: caddy:latest
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "444:443"
|
||||||
|
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
|
||||||
|
volumes:
|
||||||
|
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)
|
||||||
|
- ./data/caddy/config:/config # Caddy-Config-Cache
|
||||||
|
depends_on:
|
||||||
|
- rvs
|
||||||
|
networks:
|
||||||
|
- aria-rvs-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
aria-rvs-net:
|
||||||
|
|||||||
+140
-7
@@ -1,6 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { WebSocketServer } = require("ws");
|
const { WebSocketServer } = require("ws");
|
||||||
|
const http = require("http");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
@@ -37,8 +38,15 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"xtts_delete_voice",
|
"xtts_delete_voice",
|
||||||
"voice_preload", "voice_ready",
|
"voice_preload", "voice_ready",
|
||||||
"stt_request", "stt_response",
|
"stt_request", "stt_response",
|
||||||
|
// Streaming-STT (Phase 1+2): App schickt PCM live an whisper-bridge,
|
||||||
|
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
|
||||||
|
"stt_stream_start", "stt_audio_chunk", "stt_stream_end",
|
||||||
|
"stt_partial", "stt_endpoint", "stt_stream_done",
|
||||||
"service_status",
|
"service_status",
|
||||||
"config_request",
|
"config_request",
|
||||||
|
"flux_request", "flux_response",
|
||||||
|
"agent_stream",
|
||||||
|
"oauth_callback",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
@@ -69,20 +77,145 @@ function cleanupRooms() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket-Server starten ────────────────────────────────────────
|
// ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
|
||||||
|
//
|
||||||
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
// Der gleiche Port handelt jetzt sowohl WebSocket-Upgrades (App, Bridges,
|
||||||
|
// Diagnostic) als auch normale HTTP-Requests (OAuth-Callbacks von Spotify,
|
||||||
|
// Google etc.). TLS-Termination passiert wie bisher vor dem RVS-Container
|
||||||
|
// (Caddy/Nginx); RVS selber bleibt plain HTTP. Wichtig fuer OAuth: aus
|
||||||
|
// Provider-Sicht ist die Callback-URL `https://{RVS_HOST}:{PORT_oeffentlich}
|
||||||
|
// /oauth/callback/{service}` — RVS schnappt den ?code=..&state=.., broadcastet
|
||||||
|
// als WS-Message `oauth_callback` und antwortet dem Browser mit einer
|
||||||
|
// schoenen "Tab schliessen"-Seite.
|
||||||
|
//
|
||||||
|
// maxPayload 100MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
||||||
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
// audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten.
|
||||||
// Default-Limit war der Killer fuer die voice_upload Pipeline.
|
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
|
||||||
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
|
// 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen
|
||||||
|
// (Code 1009 message too big, Bridge crashed im cleanup). 100 MB
|
||||||
|
// deckt bis ~70 MB binaer ab; groessere Files werden Bridge-seitig
|
||||||
|
// abgewiesen (siehe file_request-Handler) bevor die WS abreisst.
|
||||||
|
const httpServer = http.createServer(handleHttpRequest);
|
||||||
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 100 * 1024 * 1024 });
|
||||||
|
|
||||||
wss.on("listening", () => {
|
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
|
||||||
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
httpServer.on("upgrade", (req, socket, head) => {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
|
||||||
// Beim Start pruefen ob eine APK da ist
|
// Beim Start pruefen ob eine APK da ist
|
||||||
const apkInfo = getLatestAPK();
|
const apkInfo = getLatestAPK();
|
||||||
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
|
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── HTTP Route-Handler ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleHttpRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// OAuth-Callback: GET /oauth/callback/{service}?code=...&state=...&error=...
|
||||||
|
// Pattern fuer Spotify, Google, Strava, GitHub, ... — alle OAuth2 Auth-Code-Flow.
|
||||||
|
// Wir broadcasten an alle Raeume (App ist nicht im selben Raum wie Bridge,
|
||||||
|
// aber Bridge schon — sie picks-up und forwardet ans Brain).
|
||||||
|
const oauthMatch = pathname.match(/^\/oauth\/callback\/([a-zA-Z0-9_-]+)\/?$/);
|
||||||
|
if (req.method === "GET" && oauthMatch) {
|
||||||
|
const service = oauthMatch[1];
|
||||||
|
const code = url.searchParams.get("code") || "";
|
||||||
|
const state = url.searchParams.get("state") || "";
|
||||||
|
const err = url.searchParams.get("error") || "";
|
||||||
|
const errDesc = url.searchParams.get("error_description") || "";
|
||||||
|
|
||||||
|
log(`OAuth-Callback: service=${service} code=${code.slice(0, 8)}... state=${state.slice(0, 8)}... err=${err}`);
|
||||||
|
|
||||||
|
const payload = { service, code, state };
|
||||||
|
if (err) {
|
||||||
|
payload.error = err;
|
||||||
|
if (errDesc) payload.errorDescription = errDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An alle Clients in allen Raeumen broadcasten — Bridge picks-up.
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
type: "oauth_callback",
|
||||||
|
payload,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
let receivers = 0;
|
||||||
|
for (const [, room] of rooms) {
|
||||||
|
for (const client of room.clients) {
|
||||||
|
if (client.readyState === 1) {
|
||||||
|
try { client.send(msg); receivers++; } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(`OAuth-Callback gebroadcastet an ${receivers} Client(s)`);
|
||||||
|
|
||||||
|
// Browser-Antwort: schoene HTML-Seite (auch bei Error)
|
||||||
|
const ok = !err;
|
||||||
|
const title = ok ? "OAuth erfolgreich" : "OAuth fehlgeschlagen";
|
||||||
|
const bodyColor = ok ? "#34C759" : "#FF3B30";
|
||||||
|
const icon = ok ? "✅" : "❌";
|
||||||
|
const subtitle = ok
|
||||||
|
? "Du kannst dieses Tab schliessen — ARIA hat den Zugang erhalten."
|
||||||
|
: `Fehler: ${escapeHtml(err)} ${errDesc ? "— " + escapeHtml(errDesc) : ""}`;
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html lang="de"><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${title} — ${escapeHtml(service)}</title>
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;padding:0;height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0D0D1A;color:#E0E0F0;}
|
||||||
|
body{display:flex;align-items:center;justify-content:center;}
|
||||||
|
.card{background:#1E1E2E;border:1px solid #2A2A3E;border-radius:12px;padding:32px;max-width:420px;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,0.4);}
|
||||||
|
.icon{font-size:64px;line-height:1;margin-bottom:16px;}
|
||||||
|
.title{font-size:20px;font-weight:600;color:${bodyColor};margin-bottom:8px;}
|
||||||
|
.service{font-size:13px;color:#8888AA;margin-bottom:20px;text-transform:uppercase;letter-spacing:0.1em;}
|
||||||
|
.sub{font-size:14px;color:#C0C0D0;line-height:1.5;}
|
||||||
|
.hint{font-size:11px;color:#666680;margin-top:24px;}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">${icon}</div>
|
||||||
|
<div class="title">${title}</div>
|
||||||
|
<div class="service">${escapeHtml(service)}</div>
|
||||||
|
<div class="sub">${subtitle}</div>
|
||||||
|
<div class="hint">Du kannst zur ARIA-App zurueckkehren.</div>
|
||||||
|
</div>
|
||||||
|
<script>setTimeout(()=>{try{window.close();}catch(e){}}, 4000);</script>
|
||||||
|
</body></html>`;
|
||||||
|
res.writeHead(ok ? 200 : 400, {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(html);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health-Endpoint
|
||||||
|
if (req.method === "GET" && pathname === "/health") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true, rooms: rooms.size }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: 404
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Not Found\n");
|
||||||
|
} catch (e) {
|
||||||
|
log(`HTTP handler error: ${e.message}`);
|
||||||
|
try { res.writeHead(500).end("Internal Server Error"); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || "").replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
wss.on("connection", (ws, req) => {
|
wss.on("connection", (ws, req) => {
|
||||||
// Token aus URL-Query lesen: ws://host:port/?token=abc123
|
// Token aus URL-Query lesen: ws://host:port/?token=abc123
|
||||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen.
|
||||||
|
|
||||||
|
Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`,
|
||||||
|
was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side
|
||||||
|
`Date.now()` (echtes UNIX-ms) → falsche Sortierung in der App-History.
|
||||||
|
|
||||||
|
Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime,
|
||||||
|
decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten
|
||||||
|
(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn).
|
||||||
|
|
||||||
|
Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix)
|
||||||
|
werden unveraendert gelassen.
|
||||||
|
|
||||||
|
Idempotent: zweimal laufen lassen ist sicher — beim zweiten Mal sind alle ts
|
||||||
|
schon UNIX-ms und werden nicht angefasst.
|
||||||
|
|
||||||
|
Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX
|
||||||
|
GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Datei nicht da: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
raw = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
entries = []
|
||||||
|
for raw_line in raw:
|
||||||
|
s = raw_line.strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(s))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ueberspringe kaputte Zeile: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
print("Datei leer")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_mtime_ms = int(os.path.getmtime(path) * 1000)
|
||||||
|
n = len(entries)
|
||||||
|
fixed = 0
|
||||||
|
|
||||||
|
# Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer
|
||||||
|
# Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem
|
||||||
|
# UNIX-ms bleiben unangetastet.
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
ts = entry.get("ts", 0)
|
||||||
|
if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD:
|
||||||
|
# Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime
|
||||||
|
new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000
|
||||||
|
entry["ts"] = new_ts
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
if fixed == 0:
|
||||||
|
print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
bak = path.with_suffix(path.suffix + ".bak")
|
||||||
|
shutil.copy2(path, bak)
|
||||||
|
print(f"Backup: {bak}")
|
||||||
|
|
||||||
|
# Atomic rewrite
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
for entry in entries:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
print(f"Migration fertig: {fixed}/{n} ts umgeschrieben")
|
||||||
|
print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}")
|
||||||
|
print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl")
|
||||||
|
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
|
||||||
|
migrate(path)
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
|
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
|
||||||
# Laeuft auf dem Gaming-PC (RTX 3060)
|
# Laeuft auf dem Gaming-PC (RTX 3060)
|
||||||
# Verbindet sich zum RVS fuer TTS/STT-Requests
|
# Verbindet sich zum RVS fuer TTS/STT-Requests
|
||||||
|
#
|
||||||
|
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root —
|
||||||
|
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen.
|
||||||
# ════════════════════════════════════════════════
|
# ════════════════════════════════════════════════
|
||||||
#
|
#
|
||||||
# Voraussetzungen:
|
# Voraussetzungen:
|
||||||
|
|||||||
@@ -375,6 +375,41 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
|||||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DEBUG-LOG ueber RVS → /shared/logs/app.log
|
||||||
|
#
|
||||||
|
# Gleiches Pattern wie in whisper-bridge: Stefan's Gamebox ist
|
||||||
|
# Windows (kein SSH), in Zukunft koennten whisper + f5tts auf
|
||||||
|
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
|
||||||
|
#
|
||||||
|
# Toggle via aria-bridge config broadcast: f5ttsDebugLog (bool).
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
_DEBUG_LOG_TO_BRIDGE: bool = False # default OFF — TTS-Renders sind teurer
|
||||||
|
# zu debuggen, normalerweise nicht noetig
|
||||||
|
|
||||||
|
|
||||||
|
async def _debug_log(ws, scope: str, message: str, level: str = "info") -> None:
|
||||||
|
"""Schickt einen app_log via RVS → /shared/logs/app.log mit platform='f5tts'.
|
||||||
|
No-op wenn Toggle aus."""
|
||||||
|
if not _DEBUG_LOG_TO_BRIDGE:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": "app_log",
|
||||||
|
"payload": {
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
"platform": "f5tts",
|
||||||
|
"level": level,
|
||||||
|
"scope": scope,
|
||||||
|
"message": str(message)[:2000],
|
||||||
|
"stack": "",
|
||||||
|
},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ── Interne Transkription via whisper-bridge ────────────────
|
# ── Interne Transkription via whisper-bridge ────────────────
|
||||||
|
|
||||||
_pending_stt: dict[str, asyncio.Future] = {}
|
_pending_stt: dict[str, asyncio.Future] = {}
|
||||||
@@ -867,6 +902,30 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
else:
|
else:
|
||||||
fut.set_result(payload.get("text") or "")
|
fut.set_result(payload.get("text") or "")
|
||||||
elif mtype == "config":
|
elif mtype == "config":
|
||||||
|
# Debug-Toggle (gleiche Semantik wie in whisper-bridge)
|
||||||
|
if "f5ttsDebugLog" in payload:
|
||||||
|
global _DEBUG_LOG_TO_BRIDGE
|
||||||
|
old = _DEBUG_LOG_TO_BRIDGE
|
||||||
|
_DEBUG_LOG_TO_BRIDGE = bool(payload.get("f5ttsDebugLog", False))
|
||||||
|
if old != _DEBUG_LOG_TO_BRIDGE:
|
||||||
|
logger.info("Debug-Log-to-Bridge: %s", "ON" if _DEBUG_LOG_TO_BRIDGE else "OFF")
|
||||||
|
# Last gasp wenn ausgeschaltet wird
|
||||||
|
if not _DEBUG_LOG_TO_BRIDGE:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": "app_log",
|
||||||
|
"payload": {
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
"platform": "f5tts",
|
||||||
|
"level": "info",
|
||||||
|
"scope": "config",
|
||||||
|
"message": "debug-log OFF (toggle aus)",
|
||||||
|
"stack": "",
|
||||||
|
},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
|
# F5-TTS-Settings aktualisieren (Modell, cfg_strength, nfe)
|
||||||
async def _update_with_status(p):
|
async def _update_with_status(p):
|
||||||
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
|
# Schaut ob ein Modell-Wechsel ansteht — falls ja:
|
||||||
@@ -912,6 +971,12 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
continue
|
continue
|
||||||
await asyncio.sleep(min(retry_s, 30))
|
await asyncio.sleep(min(retry_s, 30))
|
||||||
retry_s = min(retry_s * 2, 30)
|
retry_s = min(retry_s * 2, 30)
|
||||||
|
# Sticky-Fallback verhindern: nach jedem Disconnect-Cycle wieder
|
||||||
|
# mit wss anfangen. Sonst klebt der Client nach einem temporaeren
|
||||||
|
# TLS-Hick auf ws:// fest und kommt nie mehr auf wss zurueck —
|
||||||
|
# genau das Problem das die App + Bridge frueher schon hatten.
|
||||||
|
use_tls = RVS_TLS
|
||||||
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
|
|||||||
+447
-24
@@ -2,8 +2,19 @@
|
|||||||
"""
|
"""
|
||||||
ARIA Whisper Bridge — laeuft auf der Gamebox (RTX 3060).
|
ARIA Whisper Bridge — laeuft auf der Gamebox (RTX 3060).
|
||||||
|
|
||||||
Empfaengt stt_request via RVS → FFmpeg-Konvertierung → faster-whisper auf GPU
|
Zwei Modi:
|
||||||
→ sendet stt_response zurueck an die aria-bridge.
|
|
||||||
|
1) Legacy One-Shot: stt_request mit komplettem Audio (mp4/wav/ogg base64)
|
||||||
|
→ ffmpeg → faster-whisper → stt_response. Bleibt fuer Fallback/alte App.
|
||||||
|
|
||||||
|
2) Streaming + ML-Endpointer (neu): App schickt live PCM-Chunks waehrend
|
||||||
|
der Aufnahme. Bridge transkribiert alle ~700ms auf dem Ringbuffer und
|
||||||
|
feuert stt_endpoint sobald der Transkript-String N ms nicht mehr
|
||||||
|
waechst. Ersetzt dB/VAD-Stille — endpointet auf SEMANTISCHE Stille,
|
||||||
|
funktioniert im Auto / mit Musik im Hintergrund.
|
||||||
|
|
||||||
|
Erwartetes PCM-Format vom App-Native-Modul: 16 kHz mono s16le (genau
|
||||||
|
das was OpenWakeWord/AudioRecord schon liefert — kein Resampling).
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||||
@@ -21,6 +32,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -47,6 +59,13 @@ WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
|||||||
|
|
||||||
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
|
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
|
|
||||||
|
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
|
||||||
|
STREAM_TRANSCRIBE_INTERVAL_MS = 700 # alle 700ms transkribieren waehrend Stream laeuft
|
||||||
|
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
|
||||||
|
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
|
||||||
|
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
|
||||||
|
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
|
||||||
|
|
||||||
|
|
||||||
class WhisperRunner:
|
class WhisperRunner:
|
||||||
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
|
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
|
||||||
@@ -55,6 +74,9 @@ class WhisperRunner:
|
|||||||
self.model_size: str = WHISPER_MODEL
|
self.model_size: str = WHISPER_MODEL
|
||||||
self.model: Optional[WhisperModel] = None
|
self.model: Optional[WhisperModel] = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Serialisiert transcribe()-Calls — faster-whisper ist nicht
|
||||||
|
# parallel-safe auf einer GPU-Instanz, plus VRAM-Fragmentierung.
|
||||||
|
self._transcribe_lock = asyncio.Lock()
|
||||||
|
|
||||||
def _load_blocking(self, size: str) -> None:
|
def _load_blocking(self, size: str) -> None:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -78,18 +100,20 @@ class WhisperRunner:
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, self._load_blocking, desired_size)
|
await loop.run_in_executor(None, self._load_blocking, desired_size)
|
||||||
|
|
||||||
async def transcribe(self, audio: np.ndarray, language: str) -> tuple[str, float]:
|
async def transcribe(self, audio: np.ndarray, language: str,
|
||||||
|
beam_size: int = 5, vad_filter: bool = True) -> tuple[str, float]:
|
||||||
if self.model is None:
|
if self.model is None:
|
||||||
return "", 0.0
|
return "", 0.0
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
segments, info = self.model.transcribe(
|
segments, info = self.model.transcribe(
|
||||||
audio, language=language, beam_size=5, vad_filter=True,
|
audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
|
||||||
)
|
)
|
||||||
text = " ".join(seg.text.strip() for seg in segments)
|
text = " ".join(seg.text.strip() for seg in segments)
|
||||||
return text, info.duration
|
return text, info.duration
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
async with self._transcribe_lock:
|
||||||
return await loop.run_in_executor(None, _run)
|
return await loop.run_in_executor(None, _run)
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +152,14 @@ def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_s16le_to_float32(pcm_bytes: bytes) -> np.ndarray:
|
||||||
|
"""16-bit signed little-endian PCM → float32 in [-1, 1]. Whisper-Format."""
|
||||||
|
if not pcm_bytes:
|
||||||
|
return np.zeros(0, dtype=np.float32)
|
||||||
|
arr = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
return arr
|
||||||
|
|
||||||
|
|
||||||
async def _send(ws, mtype: str, payload: dict) -> None:
|
async def _send(ws, mtype: str, payload: dict) -> None:
|
||||||
try:
|
try:
|
||||||
await ws.send(json.dumps({
|
await ws.send(json.dumps({
|
||||||
@@ -139,14 +171,326 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
|||||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DEBUG-LOG ueber RVS → /shared/logs/app.log
|
||||||
|
#
|
||||||
|
# Stefan's Gamebox ist Windows, kein SSH → wir brauchen Whisper-Bridge-
|
||||||
|
# Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS,
|
||||||
|
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
|
||||||
|
# Logs-Tab zeigen sie dann mit platform="whisper".
|
||||||
|
#
|
||||||
|
# Toggle via aria-bridge config broadcast: whisperDebugLog (bool).
|
||||||
|
# Default ON solange wir Phase-1/2-Pipeline einfahren — danach
|
||||||
|
# defaultet aria-bridge ihn aus damit kein Spam.
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
_DEBUG_LOG_TO_BRIDGE: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
async def _debug_log(ws, scope: str, message: str, level: str = "info") -> None:
|
||||||
|
"""Schickt einen app_log via RVS → landet in /shared/logs/app.log mit
|
||||||
|
platform='whisper'. Idempotent: wenn Toggle aus → no-op."""
|
||||||
|
if not _DEBUG_LOG_TO_BRIDGE:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": "app_log",
|
||||||
|
"payload": {
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
"platform": "whisper",
|
||||||
|
"level": level,
|
||||||
|
"scope": scope,
|
||||||
|
"message": str(message)[:2000],
|
||||||
|
"stack": "",
|
||||||
|
},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# STREAMING-SESSIONS
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamSession:
|
||||||
|
"""State pro laufendem Streaming-STT-Request."""
|
||||||
|
request_id: str
|
||||||
|
audio_request_id: str
|
||||||
|
language: str
|
||||||
|
model: str
|
||||||
|
endpoint_ms: int
|
||||||
|
hard_cap_ms: int
|
||||||
|
voice: str = "" # echoed back via stt_endpoint fuer ChatScreen → TTS-Override
|
||||||
|
speed: float = 1.0
|
||||||
|
interrupted: bool = False # Barge-In
|
||||||
|
location: Optional[dict] = None
|
||||||
|
sample_rate: int = 16000
|
||||||
|
pcm_buffer: bytearray = field(default_factory=bytearray)
|
||||||
|
started_at: float = field(default_factory=time.time)
|
||||||
|
last_chunk_at: float = field(default_factory=time.time)
|
||||||
|
last_partial: str = ""
|
||||||
|
last_growth_at: float = 0.0
|
||||||
|
last_transcribe_at: float = 0.0
|
||||||
|
closed: bool = False # nach stream_end gesetzt
|
||||||
|
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Haelt alle aktiven Streaming-Sessions + Endpointer-Loop."""
|
||||||
|
|
||||||
|
def __init__(self, runner: WhisperRunner) -> None:
|
||||||
|
self.runner = runner
|
||||||
|
self._sessions: dict[str, StreamSession] = {}
|
||||||
|
self._ws = None # wird vom run_loop gesetzt
|
||||||
|
self._loop_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
def attach_ws(self, ws) -> None:
|
||||||
|
self._ws = ws
|
||||||
|
|
||||||
|
def detach_ws(self) -> None:
|
||||||
|
self._ws = None
|
||||||
|
# Sessions ueberleben Disconnect — der naechste Reconnect kann sie weiter
|
||||||
|
# fuettern, falls die App das gleiche requestId nochmal schickt.
|
||||||
|
# Aber unsere App startet nach Reconnect eine neue Aufnahme; alte Sessions
|
||||||
|
# werden vom Cleanup-Task entsorgt nach STREAM_SESSION_TTL_S.
|
||||||
|
|
||||||
|
def start_session(self, payload: dict) -> Optional[StreamSession]:
|
||||||
|
request_id = payload.get("requestId", "").strip()
|
||||||
|
if not request_id:
|
||||||
|
logger.warning("stt_stream_start ohne requestId — ignoriert")
|
||||||
|
return None
|
||||||
|
if request_id in self._sessions:
|
||||||
|
logger.warning("stt_stream_start: requestId %s schon aktiv — alte Session wird ersetzt",
|
||||||
|
request_id[:8])
|
||||||
|
try:
|
||||||
|
endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS
|
||||||
|
try:
|
||||||
|
hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS
|
||||||
|
try:
|
||||||
|
speed = float(payload.get("speed") or 1.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
speed = 1.0
|
||||||
|
session = StreamSession(
|
||||||
|
request_id=request_id,
|
||||||
|
audio_request_id=payload.get("audioRequestId", "") or "",
|
||||||
|
language=payload.get("language") or WHISPER_LANGUAGE,
|
||||||
|
model=payload.get("model") or self.runner.model_size or WHISPER_MODEL,
|
||||||
|
endpoint_ms=endpoint_ms,
|
||||||
|
hard_cap_ms=hard_cap_ms,
|
||||||
|
voice=payload.get("voice", "") or "",
|
||||||
|
speed=speed,
|
||||||
|
interrupted=bool(payload.get("interrupted", False)),
|
||||||
|
location=payload.get("location") or None,
|
||||||
|
sample_rate=int(payload.get("sampleRate") or 16000),
|
||||||
|
)
|
||||||
|
self._sessions[request_id] = session
|
||||||
|
logger.info("Stream-Session offen: id=%s lang=%s model=%s endpointMs=%d hardCapMs=%d voice=%r",
|
||||||
|
request_id[:8], session.language, session.model,
|
||||||
|
session.endpoint_ms, session.hard_cap_ms, session.voice or "(default)")
|
||||||
|
return session
|
||||||
|
|
||||||
|
def feed_chunk(self, payload: dict) -> bool:
|
||||||
|
request_id = payload.get("requestId", "")
|
||||||
|
session = self._sessions.get(request_id)
|
||||||
|
if session is None or session.closed:
|
||||||
|
return False
|
||||||
|
pcm_b64 = payload.get("pcm", "")
|
||||||
|
if not pcm_b64:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
pcm_bytes = base64.b64decode(pcm_b64)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Stream %s: ungueltige base64-PCM-Daten", request_id[:8])
|
||||||
|
return False
|
||||||
|
session.pcm_buffer.extend(pcm_bytes)
|
||||||
|
session.last_chunk_at = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def end_session(self, request_id: str) -> Optional[StreamSession]:
|
||||||
|
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
|
||||||
|
Final-Transcribe + Cleanup."""
|
||||||
|
session = self._sessions.get(request_id)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
session.closed = True
|
||||||
|
return session
|
||||||
|
|
||||||
|
def drop(self, request_id: str) -> None:
|
||||||
|
self._sessions.pop(request_id, None)
|
||||||
|
|
||||||
|
async def run_endpointer(self) -> None:
|
||||||
|
"""Background-Loop: alle ~200ms ueber alle Sessions iterieren."""
|
||||||
|
logger.info("Endpointer-Loop gestartet (transcribe-interval=%dms, default-endpoint=%dms)",
|
||||||
|
STREAM_TRANSCRIBE_INTERVAL_MS, STREAM_DEFAULT_ENDPOINT_MS)
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
now = time.time()
|
||||||
|
# Snapshot — sonst RuntimeError wenn wir waehrend Iteration sessions[]
|
||||||
|
# mutieren (Endpoint-Drop).
|
||||||
|
for sid, sess in list(self._sessions.items()):
|
||||||
|
try:
|
||||||
|
await self._tick_session(sess, now)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Endpointer-Tick crashed (session=%s)", sid[:8])
|
||||||
|
|
||||||
|
# Cleanup: tote Sessions (ohne Chunk seit STREAM_SESSION_TTL_S)
|
||||||
|
for sid, sess in list(self._sessions.items()):
|
||||||
|
if now - sess.last_chunk_at > STREAM_SESSION_TTL_S:
|
||||||
|
logger.info("Stream %s: TTL ueberschritten (ohne Daten seit %.0fs) — drop",
|
||||||
|
sid[:8], now - sess.last_chunk_at)
|
||||||
|
self.drop(sid)
|
||||||
|
|
||||||
|
async def _tick_session(self, sess: StreamSession, now: float) -> None:
|
||||||
|
ws = self._ws
|
||||||
|
if ws is None:
|
||||||
|
return # disconnected — Endpointer pausiert bis Reconnect
|
||||||
|
|
||||||
|
audio_ms = self._buffer_duration_ms(sess)
|
||||||
|
|
||||||
|
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
|
||||||
|
elapsed_ms = (now - sess.started_at) * 1000.0
|
||||||
|
if elapsed_ms > sess.hard_cap_ms and not sess.endpoint_sent and not sess.closed:
|
||||||
|
logger.info("Stream %s: HardCap %dms erreicht — forciere Endpoint",
|
||||||
|
sess.request_id[:8], sess.hard_cap_ms)
|
||||||
|
await self._finalize(sess, ws, reason="hardcap")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Closed (stream_end empfangen) → finalisieren mit dem gesammelten Buffer
|
||||||
|
if sess.closed and not sess.endpoint_sent:
|
||||||
|
await self._finalize(sess, ws, reason="stream_end")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Noch zu wenig Audio fuer eine erste Transkription
|
||||||
|
if audio_ms < STREAM_MIN_AUDIO_MS:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transcribe-Throttling
|
||||||
|
since_last = (now - sess.last_transcribe_at) * 1000.0
|
||||||
|
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
|
||||||
|
return
|
||||||
|
|
||||||
|
sess.last_transcribe_at = now
|
||||||
|
try:
|
||||||
|
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Kleinere beam_size fuer Streaming-Partials — wir wollen Latenz,
|
||||||
|
# nicht maximale Genauigkeit. Final-Transcribe (in _finalize) faehrt
|
||||||
|
# dann mit beam_size=5.
|
||||||
|
text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=1, vad_filter=True)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
|
||||||
|
return
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
grew = bool(text) and text != sess.last_partial
|
||||||
|
if grew:
|
||||||
|
sess.last_partial = text
|
||||||
|
sess.last_growth_at = now
|
||||||
|
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
|
||||||
|
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
|
||||||
|
await _send(ws, "stt_partial", {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
await _debug_log(ws, "stream.partial",
|
||||||
|
f"id={sess.request_id[:12]} text={text[:80]!r}")
|
||||||
|
else:
|
||||||
|
# Stagnation pruefen — Endpoint-Bedingung
|
||||||
|
if sess.last_growth_at == 0.0:
|
||||||
|
# Noch gar kein Text erkannt. Wenn der User gar nichts sagt
|
||||||
|
# springt Brain irgendwann aus eigenem Conversation-Window-
|
||||||
|
# Timeout in der App raus; wir machen hier nix.
|
||||||
|
return
|
||||||
|
silence_ms = (now - sess.last_growth_at) * 1000.0
|
||||||
|
if silence_ms >= sess.endpoint_ms and not sess.endpoint_sent:
|
||||||
|
logger.info("Stream %s: Endpoint nach %dms ohne neuen Text — Text=%r",
|
||||||
|
sess.request_id[:8], int(silence_ms), sess.last_partial[:80])
|
||||||
|
await self._finalize(sess, ws, reason="endpoint")
|
||||||
|
|
||||||
|
def _buffer_duration_ms(self, sess: StreamSession) -> float:
|
||||||
|
# 16-bit s16le mono → 2 bytes pro Sample
|
||||||
|
samples = len(sess.pcm_buffer) // 2
|
||||||
|
if samples == 0:
|
||||||
|
return 0.0
|
||||||
|
return (samples / sess.sample_rate) * 1000.0
|
||||||
|
|
||||||
|
async def _finalize(self, sess: StreamSession, ws, reason: str) -> None:
|
||||||
|
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
|
||||||
|
feuert stt_endpoint + stt_stream_done, droppt Session."""
|
||||||
|
if sess.endpoint_sent:
|
||||||
|
return
|
||||||
|
sess.endpoint_sent = True
|
||||||
|
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||||
|
if audio.size == 0:
|
||||||
|
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
|
||||||
|
final_text = ""
|
||||||
|
stt_ms = 0
|
||||||
|
duration_s = 0.0
|
||||||
|
else:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
final_text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=5, vad_filter=True)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Stream %s: Final-Transcribe crashed", sess.request_id[:8])
|
||||||
|
final_text = sess.last_partial # fallback auf letzten Partial
|
||||||
|
stt_ms = int((time.time() - t0) * 1000)
|
||||||
|
duration_s = audio.size / 16000.0
|
||||||
|
final_text = final_text.strip()
|
||||||
|
|
||||||
|
logger.info("Stream %s: FINAL (reason=%s, %.1fs Audio, %dms): %r",
|
||||||
|
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
|
||||||
|
await _debug_log(ws, "stream.final",
|
||||||
|
f"id={sess.request_id[:12]} reason={reason} "
|
||||||
|
f"audio={duration_s:.1f}s stt={stt_ms}ms text={final_text[:80]!r}")
|
||||||
|
|
||||||
|
# stt_endpoint: das ist DAS Event auf das aria-bridge horcht fuer den
|
||||||
|
# Brain-Shortcut. Enthaelt alle Felder die bisher in 'audio' lagen,
|
||||||
|
# ohne den Audio-Roundtrip (App → aria-bridge → whisper → aria-bridge).
|
||||||
|
endpoint_payload = {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": final_text,
|
||||||
|
"reason": reason,
|
||||||
|
"durationS": duration_s,
|
||||||
|
"sttMs": stt_ms,
|
||||||
|
"voice": sess.voice,
|
||||||
|
"speed": sess.speed,
|
||||||
|
"interrupted": sess.interrupted,
|
||||||
|
}
|
||||||
|
if sess.location:
|
||||||
|
endpoint_payload["location"] = sess.location
|
||||||
|
await _send(ws, "stt_endpoint", endpoint_payload)
|
||||||
|
|
||||||
|
# stt_stream_done: an die App — damit sie ihre Recording-State-Machine
|
||||||
|
# zurueck auf armed setzt (Mikro aus, ggf. Wake-Word wieder an).
|
||||||
|
await _send(ws, "stt_stream_done", {
|
||||||
|
"requestId": sess.request_id,
|
||||||
|
"audioRequestId": sess.audio_request_id,
|
||||||
|
"text": final_text,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.drop(sess.request_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# LEGACY ONE-SHOT (unveraendert)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
|
async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
|
||||||
request_id = payload.get("requestId", "")
|
request_id = payload.get("requestId", "")
|
||||||
audio_b64 = payload.get("audio", "")
|
audio_b64 = payload.get("audio", "")
|
||||||
mime_type = payload.get("mimeType", "audio/mp4")
|
mime_type = payload.get("mimeType", "audio/mp4")
|
||||||
# Modell-Auswahl:
|
|
||||||
# payload.model gesetzt → nimm das (aria-bridge sendet's basierend auf Config)
|
|
||||||
# sonst + Modell geladen → behalt das aktuelle (kein sinnloser Swap)
|
|
||||||
# sonst → fallback auf ENV-Default
|
|
||||||
model = payload.get("model") or (runner.model_size if runner.model is not None else WHISPER_MODEL)
|
model = payload.get("model") or (runner.model_size if runner.model is not None else WHISPER_MODEL)
|
||||||
language = payload.get("language") or WHISPER_LANGUAGE
|
language = payload.get("language") or WHISPER_LANGUAGE
|
||||||
|
|
||||||
@@ -156,8 +500,6 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
t_load = time.time()
|
t_load = time.time()
|
||||||
# Falls Modell noch nicht geladen (Race-Condition: stt_request vor config)
|
|
||||||
# → Status-Broadcast loading→ready damit der App-Banner aufpoppt
|
|
||||||
needs_load = runner.model is None or runner.model_size != model
|
needs_load = runner.model is None or runner.model_size != model
|
||||||
if needs_load:
|
if needs_load:
|
||||||
await _broadcast_status(ws, "loading", model=model)
|
await _broadcast_status(ws, "loading", model=model)
|
||||||
@@ -205,7 +547,11 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
|||||||
await _send(ws, "service_status", payload)
|
await _send(ws, "service_status", payload)
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(runner: WhisperRunner) -> None:
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# WS-LOOP
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
||||||
use_tls = RVS_TLS
|
use_tls = RVS_TLS
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
@@ -216,20 +562,12 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s", masked)
|
logger.info("Verbinde zu RVS: %s", masked)
|
||||||
# max_size 50MB damit grosse stt_request (Voice-Cloning-WAVs als
|
|
||||||
# base64 koennen mehrere MB werden) nicht das Frame-Limit sprengen
|
|
||||||
# und die Verbindung mit 1009 'message too big' killen.
|
|
||||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
||||||
logger.info("RVS verbunden")
|
logger.info("RVS verbunden")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
sessions.attach_ws(ws)
|
||||||
|
|
||||||
# Initialer Status-Broadcast — uebertont alten "ready"-State
|
|
||||||
# im App/Diagnostic Banner (sonst denkt der User noch alles ist
|
|
||||||
# gut von vorher). Wenn Modell schon geladen → ready, sonst
|
|
||||||
# loading mit aktuellem (Default-)Namen.
|
|
||||||
# Plus: config_request an aria-bridge — wir wissen nicht ob
|
|
||||||
# sie auch grad reconnected hat oder schon laenger online ist.
|
|
||||||
async def _initial_handshake():
|
async def _initial_handshake():
|
||||||
try:
|
try:
|
||||||
if runner.model is not None:
|
if runner.model is not None:
|
||||||
@@ -241,6 +579,11 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
await _broadcast_status(ws, "loading", model=init_model)
|
await _broadcast_status(ws, "loading", model=init_model)
|
||||||
logger.info("Initial: sende config_request an aria-bridge")
|
logger.info("Initial: sende config_request an aria-bridge")
|
||||||
await _send(ws, "config_request", {"service": "whisper"})
|
await _send(ws, "config_request", {"service": "whisper"})
|
||||||
|
# Startup-Marker — App-Logs zeigen damit ob Streaming-Code
|
||||||
|
# ueberhaupt aktiv ist (Stefan baut auf Gamebox via PS,
|
||||||
|
# Build/Restart kann unbeabsichtigt alte Version weiterfahren).
|
||||||
|
await _debug_log(ws, "boot",
|
||||||
|
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Initial-Handshake crashed: %s", e)
|
logger.exception("Initial-Handshake crashed: %s", e)
|
||||||
asyncio.create_task(_initial_handshake())
|
asyncio.create_task(_initial_handshake())
|
||||||
@@ -259,9 +602,84 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
logger.info("stt_request empfangen (id=%s, %dKB Audio)",
|
logger.info("stt_request empfangen (id=%s, %dKB Audio)",
|
||||||
req_id[:8] if req_id != "?" else "?", audio_len // 1365)
|
req_id[:8] if req_id != "?" else "?", audio_len // 1365)
|
||||||
asyncio.create_task(handle_stt_request(ws, payload, runner))
|
asyncio.create_task(handle_stt_request(ws, payload, runner))
|
||||||
|
|
||||||
|
elif mtype == "stt_stream_start":
|
||||||
|
await _debug_log(ws, "stream.start",
|
||||||
|
f"received id={payload.get('requestId', '?')[:12]} "
|
||||||
|
f"audioReqId={payload.get('audioRequestId', '?')[:16]} "
|
||||||
|
f"endpointMs={payload.get('endpointMs')} "
|
||||||
|
f"hardCapMs={payload.get('hardCapMs')}")
|
||||||
|
# Ggf. Modell sicherstellen — sonst antwortet der erste
|
||||||
|
# transcribe-Call mit Leerstring weil Model None.
|
||||||
|
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
|
||||||
|
needs_load = (runner.model is None) or (target_model != runner.model_size)
|
||||||
|
if needs_load:
|
||||||
|
async def _load_then_start(p, target):
|
||||||
|
await _broadcast_status(ws, "loading", model=target)
|
||||||
|
try:
|
||||||
|
await runner.ensure_loaded(target)
|
||||||
|
await _broadcast_status(ws, "ready", model=runner.model_size)
|
||||||
|
except Exception as e:
|
||||||
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
|
return
|
||||||
|
sessions.start_session(p)
|
||||||
|
asyncio.create_task(_load_then_start(payload, target_model))
|
||||||
|
else:
|
||||||
|
sessions.start_session(payload)
|
||||||
|
|
||||||
|
elif mtype == "stt_audio_chunk":
|
||||||
|
ok = sessions.feed_chunk(payload)
|
||||||
|
if not ok:
|
||||||
|
# Sehr verbose im Schlimmstfall — debug-Level reicht.
|
||||||
|
logger.debug("stt_audio_chunk: unbekannte/closed session %s",
|
||||||
|
payload.get("requestId", "")[:8])
|
||||||
|
await _debug_log(ws, "stream.chunk.reject",
|
||||||
|
f"unknown/closed session id={payload.get('requestId', '?')[:12]}",
|
||||||
|
level="warn")
|
||||||
|
else:
|
||||||
|
# Nur alle 25 Chunks loggen (=5s Audio) — sonst Spam.
|
||||||
|
try:
|
||||||
|
seq = int(payload.get("seq", 0) or 0)
|
||||||
|
if seq % 25 == 0:
|
||||||
|
await _debug_log(ws, "stream.chunk",
|
||||||
|
f"id={payload.get('requestId', '?')[:12]} seq={seq}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif mtype == "stt_stream_end":
|
||||||
|
req_id = payload.get("requestId", "")
|
||||||
|
logger.info("stt_stream_end empfangen: id=%s reason=%s",
|
||||||
|
req_id[:8], payload.get("reason", ""))
|
||||||
|
await _debug_log(ws, "stream.end",
|
||||||
|
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
|
||||||
|
sessions.end_session(req_id)
|
||||||
|
|
||||||
elif mtype == "config":
|
elif mtype == "config":
|
||||||
|
# Debug-Toggle: aria-bridge broadcastet jetzt whisperDebugLog
|
||||||
|
# damit Stefan im laufenden Betrieb via Diagnostic-Settings
|
||||||
|
# die Logs an/aus schalten kann.
|
||||||
|
if "whisperDebugLog" in payload:
|
||||||
|
global _DEBUG_LOG_TO_BRIDGE
|
||||||
|
old = _DEBUG_LOG_TO_BRIDGE
|
||||||
|
_DEBUG_LOG_TO_BRIDGE = bool(payload.get("whisperDebugLog", False))
|
||||||
|
if old != _DEBUG_LOG_TO_BRIDGE:
|
||||||
|
logger.info("Debug-Log-to-Bridge: %s", "ON" if _DEBUG_LOG_TO_BRIDGE else "OFF")
|
||||||
|
# Last gasp wenn ausgeschaltet wird damit Stefan im Log sieht
|
||||||
|
# dass der Toggle griff.
|
||||||
|
if not _DEBUG_LOG_TO_BRIDGE:
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"type": "app_log",
|
||||||
|
"payload": {
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
"platform": "whisper",
|
||||||
|
"level": "info",
|
||||||
|
"scope": "config",
|
||||||
|
"message": "debug-log OFF (toggle aus)",
|
||||||
|
"stack": "",
|
||||||
|
},
|
||||||
|
"timestamp": int(time.time() * 1000),
|
||||||
|
}))
|
||||||
new_model = payload.get("whisperModel") or WHISPER_MODEL
|
new_model = payload.get("whisperModel") or WHISPER_MODEL
|
||||||
# Laden wenn (a) noch nix geladen, oder (b) Modell wechselt
|
|
||||||
needs_load = (runner.model is None) or (new_model != runner.model_size)
|
needs_load = (runner.model is None) or (new_model != runner.model_size)
|
||||||
if needs_load:
|
if needs_load:
|
||||||
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
|
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
|
||||||
@@ -280,11 +698,10 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||||
asyncio.create_task(_swap_with_status(new_model))
|
asyncio.create_task(_swap_with_status(new_model))
|
||||||
else:
|
else:
|
||||||
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
|
|
||||||
# ob stt_request ueberhaupt durch den RVS kommt
|
|
||||||
logger.debug("Unbeachteter Type: %s", mtype)
|
logger.debug("Unbeachteter Type: %s", mtype)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Verbindung verloren: %s", e)
|
logger.warning("Verbindung verloren: %s", e)
|
||||||
|
sessions.detach_ws()
|
||||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
||||||
use_tls = False
|
use_tls = False
|
||||||
@@ -292,6 +709,8 @@ async def run_loop(runner: WhisperRunner) -> None:
|
|||||||
continue
|
continue
|
||||||
await asyncio.sleep(min(retry_s, 30))
|
await asyncio.sleep(min(retry_s, 30))
|
||||||
retry_s = min(retry_s * 2, 30)
|
retry_s = min(retry_s * 2, 30)
|
||||||
|
use_tls = RVS_TLS
|
||||||
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
@@ -299,7 +718,11 @@ async def main() -> None:
|
|||||||
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
|
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
runner = WhisperRunner()
|
runner = WhisperRunner()
|
||||||
await run_loop(runner)
|
sessions = SessionManager(runner)
|
||||||
|
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
|
||||||
|
# schlaeft solange das nicht gesetzt ist.
|
||||||
|
asyncio.create_task(sessions.run_endpointer())
|
||||||
|
await run_loop(runner, sessions)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user