Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -16,11 +16,21 @@ ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
|
||||
# Alle muessen den gleichen Host, Port und Token nutzen.
|
||||
|
||||
# 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
|
||||
|
||||
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
|
||||
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://)
|
||||
RVS_TLS=true
|
||||
|
||||
@@ -35,6 +45,21 @@ RVS_TLS_FALLBACK=true
|
||||
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
|
||||
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 ───────────────────
|
||||
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
|
||||
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
|
||||
|
||||
@@ -37,6 +37,12 @@ aria-data/brain/qdrant/
|
||||
# Diagnostic-State (aktive Session etc.)
|
||||
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_modules/
|
||||
npm-debug.log*
|
||||
|
||||
@@ -301,6 +301,16 @@ aria-brain → Antwort → Bridge → RVS → App
|
||||
buchstabiert (`USB` → "U S B", `XTTS` → "X T T S").
|
||||
- **Wake-Word**: openwakeword (lokales Mikrofon auf der VM, optional)
|
||||
- **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
|
||||
|
||||
@@ -314,6 +324,111 @@ 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
|
||||
|
||||
Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
@@ -332,7 +447,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
|
||||
**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.
|
||||
- **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
|
||||
|
||||
@@ -342,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
|
||||
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
|
||||
- **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`
|
||||
|
||||
---
|
||||
|
||||
@@ -377,7 +496,10 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||
- **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
|
||||
- **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)
|
||||
- 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 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
|
||||
@@ -597,16 +719,27 @@ tar -czf aria-backup-$(date +%Y%m%d).tar.gz aria-data/
|
||||
|
||||
## 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.
|
||||
|
||||
```bash
|
||||
cd rvs
|
||||
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
|
||||
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:**
|
||||
- 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
|
||||
- Heartbeat + tote Verbindungen aufraeumen
|
||||
|
||||
@@ -619,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.
|
||||
|
||||
**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)
|
||||
@@ -895,6 +1033,18 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||
- [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] **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
|
||||
|
||||
|
||||
+27
-1
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { PermissionsAndroid, Platform, 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 { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
@@ -16,6 +16,7 @@ import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
import gpsTrackingService from './src/services/gpsTracking';
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
@@ -99,8 +100,33 @@ const App: React.FC = () => {
|
||||
};
|
||||
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
|
||||
return () => {
|
||||
appStateSub.remove();
|
||||
rvs.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10506
|
||||
versionName "0.1.5.6"
|
||||
versionCode 10800
|
||||
versionName "0.1.8.0"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -9,14 +9,26 @@
|
||||
<!-- 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_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_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||
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_MEDIA_PLAYBACK" />
|
||||
<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" />
|
||||
<!-- 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
|
||||
android:name=".MainApplication"
|
||||
@@ -52,6 +64,6 @@
|
||||
<service
|
||||
android:name=".AriaPlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback|microphone" />
|
||||
android:foregroundServiceType="mediaPlayback|microphone|location" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -5,9 +5,11 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
@@ -32,15 +34,26 @@ class AriaPlaybackService : Service() {
|
||||
|
||||
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() {
|
||||
super.onCreate()
|
||||
ensureNotificationChannel()
|
||||
acquireWakeLock()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
||||
currentReason = 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 {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
||||
} catch (e: Exception) {
|
||||
@@ -53,10 +66,36 @@ class AriaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseWakeLock()
|
||||
Log.i(TAG, "Foreground-Service gestoppt")
|
||||
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
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
|
||||
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
||||
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
|
||||
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
||||
* 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()
|
||||
* 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
|
||||
fun kickReleaseMedia(promise: Promise) {
|
||||
|
||||
@@ -21,6 +21,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(ApkInstallerPackage())
|
||||
add(AudioFocusPackage())
|
||||
add(PcmStreamPlayerPackage())
|
||||
add(PcmStreamRecorderPackage())
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
@@ -11,6 +12,7 @@ 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.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.facebook.react.bridge.Promise
|
||||
@@ -80,6 +82,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
private var ns: NoiseSuppressor? = 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
|
||||
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
||||
private var melProcessedIdx: Int = 0
|
||||
@@ -198,6 +207,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
running.set(true)
|
||||
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 {
|
||||
isDaemon = true
|
||||
start()
|
||||
@@ -232,6 +256,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
try { audioRecord?.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
releaseAudioEffects()
|
||||
releaseWakeLock()
|
||||
Log.i(TAG, "Lauschen gestoppt")
|
||||
promise.resolve(true)
|
||||
}
|
||||
@@ -245,10 +270,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
||||
try { audioRecord?.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
releaseAudioEffects()
|
||||
releaseWakeLock()
|
||||
disposeSessions()
|
||||
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
|
||||
fun isAvailable(promise: Promise) {
|
||||
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
||||
|
||||
@@ -361,6 +361,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
||||
writerThread = null
|
||||
val t = track
|
||||
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.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.ReactMethod
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* 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 modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||
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
|
||||
fun start(promise: Promise) {
|
||||
@@ -59,7 +65,7 @@ class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJ
|
||||
handleStateChange(state)
|
||||
}
|
||||
}
|
||||
tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
|
||||
tm.registerTelephonyCallback(callbackExecutor, cb)
|
||||
modernCallback = cb
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.5.6",
|
||||
"version": "0.1.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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;
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
AppState,
|
||||
NativeModules,
|
||||
Alert,
|
||||
Pressable,
|
||||
Share,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -149,6 +151,22 @@ const MAX_THOUGHTS = 500;
|
||||
// im Gespraechsmodus bei sehr vielen Nachrichten.
|
||||
const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
|
||||
msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs;
|
||||
|
||||
// Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain
|
||||
// Kontext hat (GPS-Position, Barge-In-Hint etc.). Diese sollen nicht in der
|
||||
// Bubble auftauchen — nur Brain sieht sie. Filtert alle aufeinanderfolgenden
|
||||
// [...]-Bloecke am Textanfang weg, inkl. der Trennleerzeichen dahinter.
|
||||
function stripSystemHints(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
// Mehrere Hints koennen aneinanderhaengen — "[A] [B] Hallo" → "Hallo"
|
||||
while (true) {
|
||||
const m = out.match(/^\s*\[[^\]]*\]\s*/);
|
||||
if (!m) break;
|
||||
out = out.slice(m[0].length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
@@ -274,11 +292,17 @@ const ChatScreen: React.FC = () => {
|
||||
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
|
||||
const lastThoughtKeyRef = useRef<string>('');
|
||||
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
|
||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||
const [ttsMuted, setTtsMuted] = useState(false);
|
||||
// System-Hints in Bubble: Bridge fuegt User-Text Praefixe wie
|
||||
// "[Stefans aktuelle GPS-Position: ...]" oder "[Hinweis: Stefan hat
|
||||
// dich gerade unterbrochen...]" hinzu damit Brain Kontext hat. Die
|
||||
// App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
|
||||
// jeden Hint mit. Toggle in Settings.
|
||||
const [showSystemHints, setShowSystemHints] = useState(false);
|
||||
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
|
||||
const localXttsVoiceRef = useRef<string>('');
|
||||
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
|
||||
@@ -446,6 +470,8 @@ const ChatScreen: React.FC = () => {
|
||||
ttsSpeedRef.current = await loadTtsSpeed();
|
||||
const gps = await AsyncStorage.getItem('aria_gps_enabled');
|
||||
setGpsEnabled(gps === 'true');
|
||||
const hints = await AsyncStorage.getItem('aria_show_hints');
|
||||
setShowSystemHints(hints === 'true'); // default false
|
||||
};
|
||||
loadSettings();
|
||||
const interval = setInterval(loadSettings, 2000);
|
||||
@@ -480,14 +506,47 @@ const ChatScreen: React.FC = () => {
|
||||
return () => { phoneCallService.stop().catch(() => {}); };
|
||||
}, []);
|
||||
|
||||
// App-Resume: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
|
||||
// gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
|
||||
// die openWakeWord sonst faelschlich als Wake-Word interpretiert.
|
||||
// App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger
|
||||
// beim Wechsel Background→Foreground:
|
||||
// (a) Cooldown 3s — Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack
|
||||
// re-route) sollen openWakeWord nicht faelschlich triggern
|
||||
// (b) Wenn die App laenger im Hintergrund war und in 'conversing'
|
||||
// zurueckkommt: vermutlich false-positive durch ein Hintergrund-
|
||||
// Geraeusch (TV, Husten etc.) waehrend Stefan gar nicht da war.
|
||||
// Wir verwerfen den Trigger und gehen zurueck zu 'armed'.
|
||||
// (c) Aktuelle Aufnahme abbrechen falls sie aus dem false-positive
|
||||
// gerade gestartet wurde.
|
||||
useEffect(() => {
|
||||
let lastState: string = AppState.currentState;
|
||||
let lastBackgroundAt = 0;
|
||||
const sub = AppState.addEventListener('change', (next) => {
|
||||
if (lastState !== 'active' && next === 'active') {
|
||||
wakeWordService.setResumeCooldown(1500);
|
||||
if (next === 'background' || next === 'inactive') {
|
||||
lastBackgroundAt = Date.now();
|
||||
} else if (lastState !== 'active' && next === 'active') {
|
||||
wakeWordService.setResumeCooldown(3000);
|
||||
const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
|
||||
// Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
|
||||
// Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
|
||||
// verwerfen + laufende Aufnahme stoppen.
|
||||
if (bgDur > 30_000) {
|
||||
wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => {
|
||||
if (discarded) {
|
||||
// Sowohl legacy als auch Streaming-Pfad abdecken
|
||||
try {
|
||||
if (audioService.isStreamingRecording()) {
|
||||
audioService.cancelStreamingRecording('wake-discarded');
|
||||
} else {
|
||||
audioService.cancelRecording();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
// PhoneCall-Listener pruefen: kann passieren dass der nach laengerer
|
||||
// Hintergrund-Zeit verloren geht (Bridge-Context recreated). Refresh
|
||||
// versucht ihn neu zu attachen falls noetig — sonst kriegt die App
|
||||
// bei display-aus / minimized keine Anruf-Events mit.
|
||||
phoneCallService.refresh().catch(() => {});
|
||||
}
|
||||
lastState = next;
|
||||
});
|
||||
@@ -838,6 +897,16 @@ const ChatScreen: React.FC = () => {
|
||||
const b64 = (message.payload.base64 as string) || '';
|
||||
const serverPath = (message.payload.serverPath as string) || '';
|
||||
const mimeType = (message.payload.mimeType as string) || '';
|
||||
// Fehler-Response (z.B. Datei zu gross, nicht gefunden) → Toast,
|
||||
// kein erneuter Versuch. Hauptverdacht: 40+ MB Videos die ueber
|
||||
// den 70 MB Bridge-Limit gehen.
|
||||
const fileErr = (message.payload as any).error as string | undefined;
|
||||
if (fileErr) {
|
||||
const fname = (message.payload.name as string) || serverPath.split('/').pop() || 'Datei';
|
||||
console.warn('[Chat] file_response Fehler fuer %s: %s', fname, fileErr);
|
||||
ToastAndroid.show(`${fname}: ${fileErr}`, ToastAndroid.LONG);
|
||||
return;
|
||||
}
|
||||
if (b64 && reqId) {
|
||||
const fileName = (message.payload.name as string) || 'download';
|
||||
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||
@@ -1111,22 +1180,39 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Gamebox-Bridges (f5tts/whisper) melden Lade-Status — Banner oben
|
||||
// Gamebox-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
|
||||
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
|
||||
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
|
||||
// Banner gucken zu muessen.
|
||||
if (message.type === ('service_status' as any)) {
|
||||
const p = message.payload as any;
|
||||
const svc = (p?.service as string) || '';
|
||||
if (!svc) return;
|
||||
const newState = (p?.state as string) || 'unknown';
|
||||
const freshlyDownloaded = p?.freshlyDownloaded === true;
|
||||
setServiceStatus(prev => ({
|
||||
...prev,
|
||||
[svc]: {
|
||||
state: (p?.state as string) || 'unknown',
|
||||
state: newState,
|
||||
model: p?.model as string | undefined,
|
||||
loadSeconds: p?.loadSeconds as number | undefined,
|
||||
error: p?.error as string | undefined,
|
||||
downloading: p?.downloading === true,
|
||||
freshlyDownloaded,
|
||||
},
|
||||
}));
|
||||
// Bei neuer Loading-Phase Banner wieder aktivieren
|
||||
if (p?.state === 'loading') setServiceBannerDismissed(false);
|
||||
if (newState === 'loading') setServiceBannerDismissed(false);
|
||||
// Download-Fertig-Toast: Bridge setzt freshlyDownloaded=true bei dem
|
||||
// 'ready'-Broadcast direkt nach einem Cache-Miss-Load. Ein einziger
|
||||
// Toast pro Modell-Download, kein State-Tracking auf App-Seite noetig.
|
||||
if (newState === 'ready' && freshlyDownloaded) {
|
||||
const niceName = svc === 'flux' ? 'FLUX' : svc === 'f5tts' ? 'F5-TTS' : svc === 'whisper' ? 'Whisper' : svc;
|
||||
const model = p?.model ? ` (${p.model})` : '';
|
||||
try {
|
||||
ToastAndroid.show(`${niceName}-Modell heruntergeladen${model} — jetzt einsatzbereit`, ToastAndroid.LONG);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1187,61 +1273,75 @@ const ChatScreen: React.FC = () => {
|
||||
return () => unsubPlayback();
|
||||
}, []);
|
||||
|
||||
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten
|
||||
// Wake Word / Gespraechsmodus: Auto-Aufnahme starten (Streaming-Modus)
|
||||
useEffect(() => {
|
||||
const unsubWake = wakeWordService.onWakeWord(async () => {
|
||||
console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
|
||||
// Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
|
||||
console.log('[Chat] Gespraechsmodus — starte Streaming-Aufnahme');
|
||||
import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'callback fired, calling startStreamingRecording')).catch(()=>{});
|
||||
|
||||
// Bubble SOFORT bauen — bevor Whisper-Bridge antwortet — damit der User
|
||||
// sieht "Es passiert was". stt_endpoint kommt typisch <1s spaeter mit
|
||||
// dem finalen Text, dann wird die Bubble ueber audioRequestId-Match
|
||||
// aktualisiert (siehe chat-Handler oben).
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
const windowMs = await loadConvWindowMs();
|
||||
const started = await audioService.startRecording(true, windowMs);
|
||||
if (started) {
|
||||
// Erst JETZT signalisieren dass das Mikro wirklich offen ist —
|
||||
// vorher war's noch in der Init-Phase. So weiss der User exakt
|
||||
// ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional
|
||||
// ueber Settings → Wake-Word abschaltbar.
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
audioRequestId,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: wasInterrupted,
|
||||
location: location || null,
|
||||
noSpeechTimeoutMs: windowMs,
|
||||
endpointMs: 1500,
|
||||
hardCapMs: 60000,
|
||||
});
|
||||
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
|
||||
if (ok) {
|
||||
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
|
||||
playWakeReadySound().catch(() => {});
|
||||
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||
import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'gong played + streaming started')).catch(()=>{});
|
||||
} else {
|
||||
// Mikrofon nicht verfuegbar, naechsten Versuch
|
||||
// Mikrofon nicht verfuegbar → Bubble wieder weg, naechster Versuch
|
||||
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||
wakeWordService.resume();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-Stop Callback: wenn Stille erkannt → Aufnahme senden + Wake Word wieder starten
|
||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 500) {
|
||||
// User hat im Fenster gesprochen → Sprachnachricht senden
|
||||
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
rvs.send('audio', {
|
||||
base64: result.base64,
|
||||
durationMs: result.durationMs,
|
||||
mimeType: result.mimeType,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: wasInterrupted,
|
||||
audioRequestId,
|
||||
...(location && { location }),
|
||||
});
|
||||
scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
|
||||
// resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
|
||||
// STT-Endpoint-Callback ersetzt den alten onSilenceDetected.
|
||||
// Feuert in 2 Faellen:
|
||||
// - text != '' → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
|
||||
// aria-bridge bekommt das gleiche Event und triggert Brain
|
||||
// direkt. App muss nix mehr senden.
|
||||
// - text == '' → cancelStreamingRecording (no-speech / hardcap / error).
|
||||
// Konversation beenden wie frueher der "kein Speech"-Fall.
|
||||
const unsubEndpoint = audioService.onSttEndpoint((ev) => {
|
||||
if (ev.text && ev.text.trim()) {
|
||||
console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
|
||||
ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
|
||||
// Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
|
||||
// chat(sender=aria) wie im Legacy-Pfad.
|
||||
} else {
|
||||
// Kein Speech im Window → Konversation beenden (Ohr geht aus oder
|
||||
// bleibt armed wenn Wake Word verfuegbar)
|
||||
// Kein Speech im Window → Konversation beenden
|
||||
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
|
||||
// Placeholder-Bubble wieder weg
|
||||
if (ev.audioRequestId) {
|
||||
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
|
||||
}
|
||||
wakeWordService.endConversation();
|
||||
// UI-State synchron halten
|
||||
if (!wakeWordService.isActive()) setWakeWordActive(false);
|
||||
}
|
||||
});
|
||||
@@ -1250,17 +1350,42 @@ const ChatScreen: React.FC = () => {
|
||||
// Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
|
||||
// (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
|
||||
const unsubBarge = wakeWordService.onBargeIn(async () => {
|
||||
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Aufnahme');
|
||||
console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Streaming-Aufnahme');
|
||||
audioService.haltAllPlayback('barge-in via wake-word');
|
||||
setAgentActivity({ activity: 'idle', tool: '' });
|
||||
rvs.send('cancel_request' as any, {});
|
||||
// Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
|
||||
await new Promise(r => setTimeout(r, 150));
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const location = await getCurrentLocation();
|
||||
const windowMs = await loadConvWindowMs();
|
||||
const started = await audioService.startRecording(true, windowMs);
|
||||
if (started) {
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
audioRequestId,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
|
||||
location: location || null,
|
||||
noSpeechTimeoutMs: windowMs,
|
||||
endpointMs: 1500,
|
||||
hardCapMs: 60000,
|
||||
});
|
||||
if (ok) {
|
||||
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
|
||||
playWakeReadySound().catch(() => {});
|
||||
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||
} else {
|
||||
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1283,7 +1408,7 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
return () => {
|
||||
unsubWake();
|
||||
unsubSilence();
|
||||
unsubEndpoint();
|
||||
unsubBarge();
|
||||
unsubTtsStart();
|
||||
unsubTtsEnd();
|
||||
@@ -1293,11 +1418,18 @@ const ChatScreen: React.FC = () => {
|
||||
// Wake Word Toggle Handler
|
||||
const toggleWakeWord = useCallback(async () => {
|
||||
if (wakeWordActive) {
|
||||
// Vor Porcupine-Stop: eventuelle laufende Aufnahme abbrechen. Sonst
|
||||
// Vor Wake-Word-Stop: eventuelle laufende Aufnahme abbrechen. Sonst
|
||||
// bleibt audioService.recordingState=='recording' haengen und der
|
||||
// normale Aufnahme-Button wirkt nicht mehr (startRecording lehnt
|
||||
// ab weil "Aufnahme laeuft bereits").
|
||||
try { await audioService.stopRecording(); } catch {}
|
||||
// ab weil "Aufnahme laeuft bereits"). Beide Pfade abdecken — legacy
|
||||
// file-Aufnahme + neue Streaming-Aufnahme.
|
||||
try {
|
||||
if (audioService.isStreamingRecording()) {
|
||||
await audioService.cancelStreamingRecording('wake-toggle-off');
|
||||
} else {
|
||||
await audioService.stopRecording();
|
||||
}
|
||||
} catch {}
|
||||
await wakeWordService.stop();
|
||||
setWakeWordActive(false);
|
||||
} else {
|
||||
@@ -1910,7 +2042,7 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
<Pressable
|
||||
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||
onLayout={e => {
|
||||
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
|
||||
@@ -1918,6 +2050,9 @@ const ChatScreen: React.FC = () => {
|
||||
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
|
||||
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
||||
}}
|
||||
onLongPress={() => openBubbleActions(item)}
|
||||
delayLongPress={500}
|
||||
android_ripple={null}
|
||||
>
|
||||
{/* Anhang-Vorschau */}
|
||||
{item.attachments?.map((att, idx) => (
|
||||
@@ -2006,7 +2141,7 @@ const ChatScreen: React.FC = () => {
|
||||
{/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
|
||||
{!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
|
||||
<MessageText
|
||||
text={item.text}
|
||||
text={showSystemHints ? item.text : stripSystemHints(item.text)}
|
||||
style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
|
||||
/>
|
||||
)}
|
||||
@@ -2048,6 +2183,15 @@ const ChatScreen: React.FC = () => {
|
||||
) : null}
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.timestamp}>{time}</Text>
|
||||
{item.text.length > 0 ? (
|
||||
<TouchableOpacity
|
||||
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||
onPress={() => openBubbleActions(item)}
|
||||
accessibilityLabel="Aktionen"
|
||||
>
|
||||
<Text style={styles.bubbleCopyIcon}>{'⎘'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
{isUser && item.deliveryStatus ? (
|
||||
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
||||
<TouchableOpacity
|
||||
@@ -2071,7 +2215,58 @@ const ChatScreen: React.FC = () => {
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
|
||||
// Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
|
||||
// teilen kann ohne den umliegenden Text mitzunehmen.
|
||||
const extractCopyables = (text: string): { label: string; value: string }[] => {
|
||||
const items: { label: string; value: string }[] = [];
|
||||
const urlRe = /https?:\/\/[^\s<>"']+/gi;
|
||||
const mailRe = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
||||
const telRe = /(?:\+?\d[\d ()/-]{6,}\d)/g;
|
||||
const seen = new Set<string>();
|
||||
const push = (label: string, value: string) => {
|
||||
const trimmed = value.trim().replace(/[,;.)\]}>]+$/g, '');
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
items.push({ label, value: trimmed });
|
||||
};
|
||||
(text.match(urlRe) || []).forEach(u => push('URL', u));
|
||||
(text.match(mailRe) || []).forEach(m => push('E-Mail', m));
|
||||
(text.match(telRe) || []).forEach(t => push('Telefon', t));
|
||||
return items.slice(0, 5); // max 5 items, mehr wird unleserlich
|
||||
};
|
||||
|
||||
// Long-Press oder ⎘-Icon auf einer Bubble. Zeigt einen Alert mit
|
||||
// "Text teilen" (= System-Share-Sheet, dort gibt's auch Zwischenablage)
|
||||
// sowie pro extrahierte URL/E-Mail/Telefonnummer eine Option um
|
||||
// gezielt nur dieses Item zu teilen.
|
||||
const openBubbleActions = (item: ChatMessage) => {
|
||||
const text = showSystemHints ? item.text : stripSystemHints(item.text);
|
||||
if (!text) return;
|
||||
const copyables = extractCopyables(text);
|
||||
const buttons: any[] = [
|
||||
{
|
||||
text: '📋 Ganzen Text teilen',
|
||||
onPress: () => Share.share({ message: text }).catch(() => {}),
|
||||
},
|
||||
];
|
||||
for (const c of copyables) {
|
||||
buttons.push({
|
||||
text: `📎 ${c.label}: ${c.value.slice(0, 32)}${c.value.length > 32 ? '…' : ''}`,
|
||||
onPress: () => Share.share({ message: c.value }).catch(() => {}),
|
||||
});
|
||||
}
|
||||
buttons.push({ text: 'Abbrechen', style: 'cancel' });
|
||||
Alert.alert(
|
||||
'Bubble-Aktionen',
|
||||
copyables.length > 0
|
||||
? 'Was moechtest du teilen / kopieren?'
|
||||
: 'Text in System-Share-Sheet oeffnen (dort "In Zwischenablage" verfuegbar).',
|
||||
buttons,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2136,7 +2331,7 @@ const ChatScreen: React.FC = () => {
|
||||
const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready');
|
||||
const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A';
|
||||
const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759';
|
||||
const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT' };
|
||||
const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={allReady ? 0.6 : 1.0}
|
||||
@@ -2146,11 +2341,16 @@ const ChatScreen: React.FC = () => {
|
||||
{entries.map(([svc, info]) => {
|
||||
let icon = '\u23F3', text = '';
|
||||
if (info.state === 'loading') {
|
||||
text = `${labels[svc] || svc}: laedt${info.model ? ' ' + info.model : ''}...`;
|
||||
icon = info.downloading ? '\u2B07' : '\u23F3'; // \u2B07 vs \u23F3
|
||||
const action = info.downloading
|
||||
? 'laedt erstmalig runter (mehrere GB, kann dauern)'
|
||||
: 'laedt';
|
||||
text = `${labels[svc] || svc}: ${action}${info.model ? ' ' + info.model : ''}...`;
|
||||
} else if (info.state === 'ready') {
|
||||
icon = '\u2705';
|
||||
icon = info.freshlyDownloaded ? '\uD83C\uDF89' : '\u2705'; // \uD83C\uDF89 vs \u2705
|
||||
const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
|
||||
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}`;
|
||||
const dl = info.freshlyDownloaded ? ' \u2014 Download fertig!' : '';
|
||||
text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}${dl}`;
|
||||
} else if (info.state === 'error') {
|
||||
icon = '\u274C';
|
||||
text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
|
||||
@@ -3044,6 +3244,12 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
bubbleCopyIcon: {
|
||||
fontSize: 13,
|
||||
color: '#8888AA',
|
||||
marginLeft: 6,
|
||||
opacity: 0.7,
|
||||
},
|
||||
fullscreenOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.95)',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Modal,
|
||||
PermissionsAndroid,
|
||||
useWindowDimensions,
|
||||
DeviceEventEmitter,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import RNFS from 'react-native-fs';
|
||||
@@ -52,11 +53,17 @@ import {
|
||||
TTS_SPEED_STORAGE_KEY,
|
||||
} 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 TriggerBrowser from '../components/TriggerBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||
import SkillBrowser from '../components/SkillBrowser';
|
||||
import OAuthBrowser from '../components/OAuthBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging, isDebugLogsToBridge, setDebugLogsToBridge, APP_LOG_EVENT } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
setWakeReadySoundEnabled,
|
||||
@@ -106,6 +113,8 @@ const SETTINGS_SECTIONS = [
|
||||
{ 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: '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: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||
] as const;
|
||||
@@ -130,7 +139,9 @@ const SettingsScreen: React.FC = () => {
|
||||
const [currentMode, setCurrentMode] = useState('normal');
|
||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||
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 [logTab, setLogTab] = useState<LogTab>('live');
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
@@ -150,6 +161,7 @@ const SettingsScreen: React.FC = () => {
|
||||
const [apkCacheInfo, setApkCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||
const [ttsCacheInfo, setTtsCacheInfo] = useState<{count: number, totalMB: number} | null>(null);
|
||||
const [verboseLogging, setVerboseLoggingState] = useState<boolean>(isVerboseLogging());
|
||||
const [debugLogsToBridge, setDebugLogsToBridgeState] = useState<boolean>(isDebugLogsToBridge());
|
||||
const [ttsSpeed, setTtsSpeed] = useState<number>(TTS_SPEED_DEFAULT);
|
||||
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
|
||||
const [wakeStatus, setWakeStatus] = useState<string>('');
|
||||
@@ -202,11 +214,17 @@ const SettingsScreen: React.FC = () => {
|
||||
// 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
|
||||
setGpsTracking(gpsTrackingService.isActive());
|
||||
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
||||
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
|
||||
gpsTrackingService.restoreFromStorage().catch(() => {});
|
||||
// Background-GPS-Toggle initial laden
|
||||
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
|
||||
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
||||
if (saved != null) {
|
||||
const n = parseFloat(saved);
|
||||
@@ -371,6 +389,19 @@ const SettingsScreen: React.FC = () => {
|
||||
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) => {
|
||||
if (message.type === 'log') {
|
||||
const entry: LogEntry = {
|
||||
@@ -506,6 +537,7 @@ const SettingsScreen: React.FC = () => {
|
||||
unsubState();
|
||||
unsubMessage();
|
||||
unsubLog();
|
||||
localLogSub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -616,6 +648,13 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- System-Hints Toggle ---
|
||||
|
||||
const handleShowSystemHintsToggle = useCallback((value: boolean) => {
|
||||
setShowSystemHints(value);
|
||||
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- XTTS Voice ---
|
||||
|
||||
const selectVoice = useCallback((voiceName: string) => {
|
||||
@@ -916,7 +955,7 @@ const SettingsScreen: React.FC = () => {
|
||||
// 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'}
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
@@ -1101,6 +1140,74 @@ const SettingsScreen: React.FC = () => {
|
||||
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
|
||||
/>
|
||||
</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 === */}
|
||||
@@ -1775,6 +1882,33 @@ const SettingsScreen: React.FC = () => {
|
||||
</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 === */}
|
||||
{currentSection === 'protocol' && (<>
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
@@ -1798,6 +1932,27 @@ const SettingsScreen: React.FC = () => {
|
||||
Warnungen und Fehler bleiben immer aktiv. Bei Bedarf einschalten zum
|
||||
Debuggen via adb logcat.
|
||||
</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 style={styles.card}>
|
||||
|
||||
@@ -36,10 +36,11 @@ function btoaSafe(bin: string): string {
|
||||
}
|
||||
|
||||
// Native Module fuer Audio-Focus (Ducking/Muten anderer Apps)
|
||||
const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
||||
const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
|
||||
AudioFocus?: {
|
||||
requestDuck: () => Promise<boolean>;
|
||||
requestExclusive: () => Promise<boolean>;
|
||||
nudgeMediaResume: () => Promise<boolean>;
|
||||
release: () => Promise<boolean>;
|
||||
kickReleaseMedia: () => Promise<boolean>;
|
||||
getMode?: () => Promise<number>;
|
||||
@@ -50,8 +51,15 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
||||
end: () => Promise<boolean>;
|
||||
stop: () => Promise<boolean>;
|
||||
};
|
||||
PcmStreamRecorder?: {
|
||||
start: () => Promise<boolean>;
|
||||
stop: () => Promise<boolean>;
|
||||
isRecording: () => Promise<boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
import rvs from './rvs';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
export interface RecordingResult {
|
||||
@@ -69,6 +77,19 @@ type RecordingStateCallback = (state: RecordingState) => void;
|
||||
type MeterCallback = (db: number) => 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 ---
|
||||
|
||||
const AUDIO_SAMPLE_RATE = 16000;
|
||||
@@ -285,6 +306,26 @@ class AudioService {
|
||||
// Position-Berechnen vom playbackStarted abziehen
|
||||
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 = '';
|
||||
// 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() {
|
||||
this.recorder = new AudioRecorderPlayer();
|
||||
this.recorder.setSubscriptionDuration(0.1); // 100ms Metering-Updates
|
||||
@@ -309,6 +350,60 @@ class AudioService {
|
||||
// bleibt liegen. 5min-Threshold damit gerade aktiv geschriebene Files sicher
|
||||
// sind. cleanupOnStartup ist async, blockt den Constructor nicht.
|
||||
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._cleanupStreamLocal('endpoint');
|
||||
this.endpointListeners.forEach(cb => {
|
||||
try { cb(ev); } catch (e) { console.warn('[Audio] endpoint listener err:', e); }
|
||||
});
|
||||
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
|
||||
@@ -332,6 +427,13 @@ class AudioService {
|
||||
}
|
||||
console.log('[Audio] AudioFocus jetzt released');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -727,6 +829,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 */
|
||||
async stopRecording(): Promise<RecordingResult | null> {
|
||||
if (this.recordingState !== 'recording') {
|
||||
@@ -789,6 +916,251 @@ 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.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. */
|
||||
async stopStreamingRecording(reason: string = 'user'): Promise<void> {
|
||||
const reqId = this.streamRequestId;
|
||||
if (!reqId) return;
|
||||
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}`);
|
||||
}
|
||||
|
||||
/** 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.)
|
||||
const ev: SttEndpointEvent = {
|
||||
audioRequestId: audioReqId,
|
||||
text: '',
|
||||
reason: `cancel:${reason}`,
|
||||
durationS: 0,
|
||||
sttMs: 0,
|
||||
};
|
||||
this.endpointListeners.forEach(cb => {
|
||||
try { cb(ev); } catch (e) { console.warn('[Audio] endpoint listener (cancel) 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 ---
|
||||
|
||||
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
* - 'tts' : ARIA spricht
|
||||
* - 'rec' : Aufnahme laeuft
|
||||
* - '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
|
||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
||||
* den hoechstprioren Slot an (tts > rec > wake > background).
|
||||
* den hoechstprioren Slot an (tts > rec > wake > location > background).
|
||||
*/
|
||||
|
||||
import { NativeModules } from 'react-native';
|
||||
@@ -27,13 +28,13 @@ interface BackgroundAudioNative {
|
||||
|
||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||
|
||||
type Slot = 'tts' | 'rec' | 'wake' | 'background';
|
||||
type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
|
||||
|
||||
const slots = new Set<Slot>();
|
||||
|
||||
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
|
||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'location', 'background'];
|
||||
|
||||
function topReason(): string {
|
||||
for (const s of PRIORITY) {
|
||||
@@ -47,6 +48,7 @@ async function applyState(): Promise<void> {
|
||||
if (slots.size === 0) {
|
||||
try { await BackgroundAudio.stop(); } catch {}
|
||||
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
|
||||
import('./logger').then(m => m.reportAppDebug('bg.stop', 'service stopped')).catch(()=>{});
|
||||
return;
|
||||
}
|
||||
const reason = topReason();
|
||||
@@ -54,8 +56,10 @@ async function applyState(): Promise<void> {
|
||||
await BackgroundAudio.start(reason);
|
||||
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
|
||||
reason, [...slots].join('+'));
|
||||
import('./logger').then(m => m.reportAppDebug('bg.start', `slot=${reason} all=[${[...slots].join(',')}]`)).catch(()=>{});
|
||||
} catch (err: any) {
|
||||
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,65 @@ export interface Memory {
|
||||
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;
|
||||
@@ -236,9 +295,12 @@ export const brainApi = {
|
||||
|
||||
// ── Triggers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Trigger (aktive + inaktive). */
|
||||
/** 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');
|
||||
return _send('/triggers/list').then((r: any) => Array.isArray(r) ? r : (r?.triggers || []));
|
||||
},
|
||||
|
||||
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||
@@ -301,6 +363,155 @@ export const brainApi = {
|
||||
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;
|
||||
|
||||
@@ -14,9 +14,62 @@
|
||||
*/
|
||||
|
||||
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 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;
|
||||
|
||||
@@ -86,6 +139,14 @@ class GpsTrackingService {
|
||||
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
|
||||
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 {
|
||||
this.watchId = Geolocation.watchPosition(
|
||||
(pos) => {
|
||||
@@ -142,6 +203,8 @@ class GpsTrackingService {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
// Location-Foreground-Service-Slot freigeben (falls vorher acquired)
|
||||
try { releaseBackgroundAudio('location'); } catch {}
|
||||
this.active = false;
|
||||
this.lastChangeAt = Date.now();
|
||||
this.notify();
|
||||
|
||||
@@ -7,10 +7,28 @@
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
import { Platform, DeviceEventEmitter } from 'react-native';
|
||||
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';
|
||||
// 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
|
||||
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
|
||||
@@ -18,6 +36,7 @@ const originalLog = console.log.bind(console);
|
||||
const noop = () => {};
|
||||
|
||||
let _verbose = true;
|
||||
let _debugLogsToBridge = false;
|
||||
|
||||
function applyState(): void {
|
||||
console.log = _verbose ? originalLog : noop;
|
||||
@@ -29,6 +48,10 @@ export async function initLogger(): Promise<void> {
|
||||
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
|
||||
_verbose = v !== 'false'; // default: true
|
||||
} catch {}
|
||||
try {
|
||||
const d = await AsyncStorage.getItem(DEBUG_LOGS_TO_BRIDGE_KEY);
|
||||
_debugLogsToBridge = d === 'true'; // default: false
|
||||
} catch {}
|
||||
applyState();
|
||||
}
|
||||
|
||||
@@ -42,6 +65,15 @@ export function setVerboseLogging(verbose: boolean): void {
|
||||
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 ────────────────────────────────────
|
||||
//
|
||||
// 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. */
|
||||
export function reportAppError(ev: AppErrorEvent): void {
|
||||
const ts = Date.now();
|
||||
try {
|
||||
rvs.send('app_log' as any, {
|
||||
ts: Date.now(),
|
||||
ts,
|
||||
platform: Platform.OS,
|
||||
level: ev.level || 'error',
|
||||
scope: ev.scope,
|
||||
@@ -73,11 +106,49 @@ export function reportAppError(ev: AppErrorEvent): void {
|
||||
} catch {
|
||||
// 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)
|
||||
// den Crash sieht.
|
||||
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
|
||||
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
|
||||
export function installGlobalCrashReporter(): void {
|
||||
|
||||
@@ -43,6 +43,42 @@ class PhoneCallService {
|
||||
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
||||
* TelephonyManager-IDLE-Event kommt. */
|
||||
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> {
|
||||
if (this.started || Platform.OS !== 'android') return false;
|
||||
@@ -82,7 +118,10 @@ class PhoneCallService {
|
||||
'PhoneCallStateChanged',
|
||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||
);
|
||||
this.telephonyAttached = true;
|
||||
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
||||
} else {
|
||||
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
|
||||
}
|
||||
} else {
|
||||
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
||||
@@ -108,6 +147,7 @@ class PhoneCallService {
|
||||
this.started = false;
|
||||
this.lastState = 'idle';
|
||||
this.interruptedByFocus = false;
|
||||
this.telephonyAttached = false;
|
||||
}
|
||||
|
||||
private _onStateChanged(state: PhoneState): void {
|
||||
|
||||
@@ -83,21 +83,39 @@ class RVSConnection {
|
||||
|
||||
// --- Verbindung ---
|
||||
|
||||
/** Verbindung zum RVS aufbauen */
|
||||
connect(): void {
|
||||
/** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
|
||||
* 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) {
|
||||
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
if (!force && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.log('info', 'Bereits verbunden');
|
||||
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.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
||||
this.usingTLSFallback = false;
|
||||
this.clearTimers();
|
||||
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
|
||||
this.establishConnection();
|
||||
}
|
||||
@@ -212,6 +230,16 @@ class RVSConnection {
|
||||
this.ws = null;
|
||||
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) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class UpdateService {
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
||||
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)
|
||||
if (Platform.OS === 'android') {
|
||||
|
||||
@@ -86,6 +86,11 @@ class WakeWordService {
|
||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||
* der openWakeWord faelschlich triggern kann. */
|
||||
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 nativeReady: boolean = false;
|
||||
@@ -174,6 +179,8 @@ class WakeWordService {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
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);
|
||||
this.setState('armed');
|
||||
return true;
|
||||
@@ -231,14 +238,24 @@ class WakeWordService {
|
||||
}
|
||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||
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) {
|
||||
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;
|
||||
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
|
||||
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
|
||||
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
|
||||
if (this.state === 'conversing') {
|
||||
import('./logger').then(m => m.reportAppDebug('wake.detect',
|
||||
`barge path: cbs=${this.bargeCallbacks.length}`)).catch(()=>{});
|
||||
this.bargeCallbacks.forEach(cb => {
|
||||
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
|
||||
});
|
||||
@@ -246,11 +263,16 @@ class WakeWordService {
|
||||
return;
|
||||
}
|
||||
this.setState('conversing');
|
||||
setTimeout(() => {
|
||||
if (this.state === 'conversing') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 200);
|
||||
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
|
||||
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
|
||||
// nicht zuendekommen, weil Hermes auf einen Native-Wake-Event wartet.
|
||||
// OpenWakeWord.stop() oben ist awaited → Mikro ist schon frei, kein
|
||||
// 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
|
||||
@@ -341,6 +363,33 @@ class WakeWordService {
|
||||
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 */
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
|
||||
@@ -21,6 +21,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
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 .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
+808
-41
@@ -18,6 +18,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from conversation import Conversation, Turn
|
||||
@@ -27,6 +31,34 @@ from proxy_client import ProxyClient, Message as ProxyMessage
|
||||
import skills as skills_mod
|
||||
import triggers as triggers_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__)
|
||||
|
||||
@@ -41,6 +73,18 @@ META_TOOLS = [
|
||||
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
|
||||
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
|
||||
"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 "
|
||||
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
|
||||
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
|
||||
@@ -58,14 +102,18 @@ META_TOOLS = [
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"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."},
|
||||
"entry_code": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Python-Code. Args lesen via os.environ['ARG_NAME']. "
|
||||
"Resultat per print() (stdout) zurueck. Bei Fehler: "
|
||||
"non-zero exit (sys.exit(1) o.ae.)."
|
||||
"Python-Code. Args lesen via os.environ['ARG_<UPPER_NAME>']. "
|
||||
"WICHTIG: der Präfix `ARG_` ist Pflicht (Konvention vom "
|
||||
"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"},
|
||||
@@ -92,6 +140,189 @@ META_TOOLS = [
|
||||
"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",
|
||||
"function": {
|
||||
@@ -215,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",
|
||||
"function": {
|
||||
@@ -355,10 +799,18 @@ def _skill_to_tool(s: dict) -> dict:
|
||||
}
|
||||
if a.get("required"):
|
||||
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 {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": f"run_{s['name']}",
|
||||
"name": safe_name,
|
||||
"description": s.get("description", "(ohne Beschreibung)"),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -437,10 +889,26 @@ class Agent:
|
||||
condition_funcs = watcher_mod.describe_functions()
|
||||
|
||||
# 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,
|
||||
triggers=all_triggers,
|
||||
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)]
|
||||
for t in self.conversation.window():
|
||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||
@@ -449,40 +917,59 @@ class Agent:
|
||||
len(hot), len(cold), len(active_skills), len(all_skills),
|
||||
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 = ""
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools)
|
||||
if result.tool_calls:
|
||||
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
|
||||
messages.append(ProxyMessage(
|
||||
role="assistant",
|
||||
content=result.content or None,
|
||||
tool_calls=[{
|
||||
"id": tc["id"], "type": "function",
|
||||
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
|
||||
} for tc in result.tool_calls],
|
||||
))
|
||||
# Tools ausfuehren + Ergebnis als role=tool zurueck
|
||||
for tc in result.tool_calls:
|
||||
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools)
|
||||
if result.tool_calls:
|
||||
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
|
||||
messages.append(ProxyMessage(
|
||||
role="tool",
|
||||
tool_call_id=tc["id"],
|
||||
name=tc["name"],
|
||||
content=tool_result[:8000],
|
||||
role="assistant",
|
||||
content=result.content or None,
|
||||
tool_calls=[{
|
||||
"id": tc["id"], "type": "function",
|
||||
"function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
|
||||
} for tc in result.tool_calls],
|
||||
))
|
||||
continue # next iteration mit Tool-Results
|
||||
# Kein Tool-Call mehr → final reply
|
||||
final_reply = (result.content or "").strip()
|
||||
break
|
||||
else:
|
||||
# Loop-Limit erreicht
|
||||
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
|
||||
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
|
||||
# Tools ausfuehren + Ergebnis als role=tool zurueck
|
||||
for tc in result.tool_calls:
|
||||
tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
|
||||
messages.append(ProxyMessage(
|
||||
role="tool",
|
||||
tool_call_id=tc["id"],
|
||||
name=tc["name"],
|
||||
content=tool_result[:8000],
|
||||
))
|
||||
continue # next iteration mit Tool-Results
|
||||
# Kein Tool-Call mehr → final reply
|
||||
final_reply = (result.content or "").strip()
|
||||
break
|
||||
else:
|
||||
# Loop-Limit erreicht
|
||||
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
|
||||
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
|
||||
|
||||
if not final_reply:
|
||||
raise RuntimeError("Leerer Reply vom Proxy")
|
||||
if not final_reply:
|
||||
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
|
||||
self.conversation.add("assistant", final_reply)
|
||||
@@ -505,6 +992,7 @@ class Agent:
|
||||
readme=arguments.get("readme", ""),
|
||||
args=arguments.get("args", []),
|
||||
pip_packages=arguments.get("pip_packages", []),
|
||||
config_schema=arguments.get("config_schema") or None,
|
||||
author="aria",
|
||||
)
|
||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||
@@ -519,6 +1007,35 @@ class Agent:
|
||||
},
|
||||
})
|
||||
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":
|
||||
items = skills_mod.list_skills(active_only=False)
|
||||
if not items:
|
||||
@@ -527,15 +1044,128 @@ class Agent:
|
||||
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
||||
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_"):
|
||||
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)
|
||||
snippet = (res.get("stdout") or "")[:2000] or "(kein stdout)"
|
||||
err = (res.get("stderr") or "")[:500]
|
||||
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
|
||||
# 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']})"
|
||||
out = f"{marker} · {res['duration_sec']}s\nstdout:\n{snippet}"
|
||||
if err:
|
||||
out += f"\nstderr:\n{err}"
|
||||
if stderr:
|
||||
out += f"\nstderr:\n{stderr}"
|
||||
return out
|
||||
if name == "trigger_timer":
|
||||
fires_at_iso = arguments.get("fires_at")
|
||||
@@ -607,6 +1237,143 @@ class Agent:
|
||||
else:
|
||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||
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":
|
||||
query = (arguments.get("query") or "").strip()
|
||||
if not query:
|
||||
|
||||
+235
-1
@@ -36,6 +36,8 @@ import metrics as metrics_mod
|
||||
import triggers as triggers_mod
|
||||
import watcher as watcher_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")
|
||||
logger = logging.getLogger("aria-brain")
|
||||
@@ -45,7 +47,13 @@ QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
|
||||
|
||||
@asynccontextmanager
|
||||
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))
|
||||
logger.info("Lifespan: Trigger-Loop gestartet")
|
||||
try:
|
||||
@@ -749,6 +757,7 @@ class SkillCreate(BaseModel):
|
||||
requires: dict = Field(default_factory=dict)
|
||||
pip_packages: list = Field(default_factory=list)
|
||||
author: str = "stefan"
|
||||
config_schema: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillRun(BaseModel):
|
||||
@@ -761,6 +770,18 @@ class SkillPatch(BaseModel):
|
||||
description: str | None = None
|
||||
active: bool | 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")
|
||||
@@ -777,6 +798,32 @@ def skills_get(name: str):
|
||||
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")
|
||||
def skills_create(body: SkillCreate):
|
||||
try:
|
||||
@@ -790,6 +837,7 @@ def skills_create(body: SkillCreate):
|
||||
requires=body.requires,
|
||||
pip_packages=body.pip_packages,
|
||||
author=body.author,
|
||||
config_schema=body.config_schema,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
@@ -826,6 +874,57 @@ def skills_logs(name: str, limit: int = 50):
|
||||
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")
|
||||
def skills_export(name: str):
|
||||
try:
|
||||
@@ -849,3 +948,138 @@ async def skills_import(request: Request, overwrite: bool = False):
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
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
|
||||
+106
-1
@@ -240,6 +240,94 @@ def build_triggers_section(
|
||||
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(
|
||||
pinned: List[MemoryPoint],
|
||||
cold: List[MemoryPoint] | None = None,
|
||||
@@ -247,8 +335,13 @@ def build_system_prompt(
|
||||
triggers: List[dict] | None = None,
|
||||
condition_vars: 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:
|
||||
"""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()]
|
||||
if skills:
|
||||
parts.append("")
|
||||
@@ -256,6 +349,18 @@ def build_system_prompt(
|
||||
if condition_vars:
|
||||
parts.append("")
|
||||
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:
|
||||
parts.append("")
|
||||
parts.append(build_cold_memory_section(cold))
|
||||
|
||||
@@ -25,7 +25,17 @@ logger = logging.getLogger(__name__)
|
||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
|
||||
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
|
||||
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
|
||||
# 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:
|
||||
@@ -62,8 +72,15 @@ class ProxyClient:
|
||||
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call
|
||||
self._client = httpx.Client(timeout=PROXY_TIMEOUT_SEC)
|
||||
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
|
||||
# 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:
|
||||
"""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"))
|
||||
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"}
|
||||
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:
|
||||
@@ -66,6 +72,44 @@ def _skill_dir(name: str) -> Path:
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
def list_skills(active_only: bool = False) -> list[dict]:
|
||||
@@ -119,6 +163,7 @@ def create_skill(
|
||||
requires: Optional[dict] = None,
|
||||
pip_packages: Optional[list[str]] = None,
|
||||
author: str = "aria",
|
||||
config_schema: Optional[list] = None,
|
||||
) -> dict:
|
||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||
|
||||
@@ -128,6 +173,7 @@ def create_skill(
|
||||
name = _safe_name(name)
|
||||
if execution not in VALID_EXECUTIONS:
|
||||
raise ValueError(f"execution muss eines von {VALID_EXECUTIONS} sein")
|
||||
_check_anti_graveyard(name)
|
||||
d = _skill_dir(name)
|
||||
if d.exists():
|
||||
raise ValueError(f"Skill '{name}' existiert bereits — erst loeschen oder updaten")
|
||||
@@ -166,6 +212,8 @@ def create_skill(
|
||||
"use_count": 0,
|
||||
"version": "1.0",
|
||||
"author": author,
|
||||
"config_schema": _normalize_config_schema(config_schema),
|
||||
"version_history": [],
|
||||
}
|
||||
write_manifest(name, manifest)
|
||||
|
||||
@@ -184,6 +232,35 @@ def create_skill(
|
||||
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:
|
||||
venv = skill_dir / "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:
|
||||
"""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)
|
||||
if manifest is None:
|
||||
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"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
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)
|
||||
logger.info("Skill aktualisiert: %s (keys=%s)", name, sorted(patch.keys()))
|
||||
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:
|
||||
d = _skill_dir(name)
|
||||
if not d.exists():
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
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)
|
||||
|
||||
|
||||
# ─── 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
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["SKILL_DIR"] = str(d)
|
||||
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
|
||||
if exec_mode == "local-venv":
|
||||
|
||||
+634
-13
@@ -20,7 +20,9 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -48,6 +50,35 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("aria-bridge")
|
||||
|
||||
|
||||
# ── TCP-Keepalive Helper ────────────────────────────────────
|
||||
#
|
||||
# Aktiviert TCP-Level Keepalive auf einer websockets-Verbindung mit
|
||||
# aggressiven Intervallen: 30s idle bis erster Probe, 10s zwischen
|
||||
# Probes, 3 verfehlte → Verbindung tot. Das deckt den Fall ab dass
|
||||
# NAT-Tabellen-Verfall die TCP-Verbindung still kills ohne RST — Linux-
|
||||
# Default braeucht sonst 2 Stunden idle bis der Kernel selber probt.
|
||||
def _enable_tcp_keepalive(ws) -> None:
|
||||
try:
|
||||
sock = ws.transport.get_extra_info("socket")
|
||||
if sock is None:
|
||||
return
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
# Linux-spezifisch — TCP_KEEPIDLE/INTVL/CNT existieren auf macOS
|
||||
# mit anderem Namen; im Container ist Linux garantiert.
|
||||
for opt, val in (
|
||||
("TCP_KEEPIDLE", 30),
|
||||
("TCP_KEEPINTVL", 10),
|
||||
("TCP_KEEPCNT", 3),
|
||||
):
|
||||
const = getattr(socket, opt, None)
|
||||
if const is not None:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, const, val)
|
||||
logger.info("[rvs] TCP-Keepalive aktiviert (idle=30s, intvl=10s, cnt=3)")
|
||||
except Exception as exc:
|
||||
logger.warning("[rvs] TCP-Keepalive konnte nicht aktiviert werden: %s", exc)
|
||||
|
||||
|
||||
# ── Konfiguration ───────────────────────────────────────────
|
||||
|
||||
VOICES_DIR = Path("/voices")
|
||||
@@ -487,6 +518,11 @@ class ARIABridge:
|
||||
self.tts_enabled = True
|
||||
self.xtts_voice = ""
|
||||
self._f5tts_config: dict = {}
|
||||
self._flux_config: dict = {}
|
||||
# Persistente TTS-Speed (App-Setting), wird aus voice_config.json
|
||||
# gelesen + bei config-Broadcasts (siehe handle config in chat)
|
||||
# geupdated. Fallback wenn der Per-Request-Override fehlt.
|
||||
self._persistent_xtts_speed: Optional[float] = None
|
||||
vc: dict = {}
|
||||
# Gespeicherte Voice-Config laden
|
||||
try:
|
||||
@@ -496,6 +532,19 @@ class ARIABridge:
|
||||
vc = json.load(f)
|
||||
self.tts_enabled = vc.get("ttsEnabled", True)
|
||||
self.xtts_voice = vc.get("xttsVoice", "")
|
||||
# Persistente TTS-Speed: vorher war's nur per-Chat-Override
|
||||
# (App schickte speed mit jeder Nachricht). Bei Diagnostic-Chat
|
||||
# OHNE App-Vor-Chat blieb _next_speed_override=None → 1.0.
|
||||
# Jetzt persistent — Bridge greift bei TTS immer auf den
|
||||
# zuletzt von der App gesetzten Wert zurueck.
|
||||
try:
|
||||
persisted_speed = float(vc.get("xttsSpeed", 1.0))
|
||||
if 0.1 <= persisted_speed <= 5.0:
|
||||
self._persistent_xtts_speed: Optional[float] = persisted_speed
|
||||
else:
|
||||
self._persistent_xtts_speed = None
|
||||
except (TypeError, ValueError):
|
||||
self._persistent_xtts_speed = None
|
||||
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
||||
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
||||
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
||||
@@ -503,9 +552,14 @@ class ARIABridge:
|
||||
"f5ttsCfgStrength", "f5ttsNfeStep"):
|
||||
if k in vc:
|
||||
self._f5tts_config[k] = vc[k]
|
||||
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s",
|
||||
# FLUX-Felder (Default-Modell + Keywords) gleicher Mechanismus
|
||||
for k in ("fluxDefaultModel", "fluxKeywordRaw", "fluxKeywordSwitch", "huggingfaceToken"):
|
||||
if k in vc:
|
||||
self._flux_config[k] = vc[k]
|
||||
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s flux=%s",
|
||||
self.tts_enabled, self.xtts_voice or "default",
|
||||
self._f5tts_config or "defaults")
|
||||
self._f5tts_config or "defaults",
|
||||
self._flux_config or "defaults")
|
||||
except Exception as e:
|
||||
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
|
||||
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
|
||||
@@ -541,6 +595,12 @@ class ARIABridge:
|
||||
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
|
||||
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
|
||||
self._remote_stt_ready: bool = False
|
||||
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
|
||||
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
|
||||
self._pending_flux: dict[str, asyncio.Future] = {}
|
||||
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
|
||||
# bei 'loading' deutlich grosszuegiger gesetzt (Modell-Download ~24 GB).
|
||||
self._remote_flux_ready: bool = False
|
||||
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
|
||||
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
|
||||
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
|
||||
@@ -1142,7 +1202,16 @@ class ARIABridge:
|
||||
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
|
||||
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
|
||||
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
|
||||
xtts_speed = self._next_speed_override or 1.0
|
||||
# Speed-Reihenfolge: Per-Request-Override (App schickte gerade) >
|
||||
# persistierter App-Setting (voice_config.json xttsSpeed) > 1.0 default.
|
||||
# Damit greift die App-Speed auch bei Diagnostic-Chats / Trigger-
|
||||
# Replies / Bridge-Restart, ohne dass die App vorher noch mal getippt
|
||||
# haben muss.
|
||||
xtts_speed = (
|
||||
self._next_speed_override
|
||||
or getattr(self, "_persistent_xtts_speed", None)
|
||||
or 1.0
|
||||
)
|
||||
|
||||
tts_text = tts_text_preview or text
|
||||
if not tts_text:
|
||||
@@ -1231,7 +1300,10 @@ class ARIABridge:
|
||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||
"whisperModel": self.stt_engine.model_size,
|
||||
}
|
||||
if getattr(self, "_persistent_xtts_speed", None) is not None:
|
||||
payload["xttsSpeed"] = self._persistent_xtts_speed
|
||||
payload.update(getattr(self, "_f5tts_config", {}) or {})
|
||||
payload.update(getattr(self, "_flux_config", {}) or {})
|
||||
await self._send_to_rvs({
|
||||
"type": "config",
|
||||
"payload": payload,
|
||||
@@ -1241,6 +1313,24 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
|
||||
|
||||
async def _persist_speed_change(self, speed: float) -> None:
|
||||
"""Schreibt nur den xttsSpeed-Eintrag in voice_config.json — der
|
||||
Rest bleibt unangetastet. Wird gerufen wenn App per chat-Event
|
||||
einen neuen Speed mitschickt (kein config-Broadcast)."""
|
||||
try:
|
||||
path = "/shared/config/voice_config.json"
|
||||
data: dict = {}
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
data = json.load(f) or {}
|
||||
data["xttsSpeed"] = speed
|
||||
os.makedirs("/shared/config", exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.info("[speed] Persistiert: %.2fx", speed)
|
||||
except Exception as exc:
|
||||
logger.warning("[speed] Persistierung fehlgeschlagen: %s", exc)
|
||||
|
||||
def _fetch_active_session(self) -> None:
|
||||
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
||||
try:
|
||||
@@ -1478,12 +1568,29 @@ class ARIABridge:
|
||||
try:
|
||||
url = f"{current_url}?token={self.rvs_token}"
|
||||
logger.info("[rvs] Verbinde: %s", current_url)
|
||||
# max_size=50MB (siehe core-Connect oben — gleicher Grund).
|
||||
async with websockets.connect(url, max_size=50 * 1024 * 1024) as ws:
|
||||
# max_size=100MB synchron zum RVS-Server (siehe rvs/server.js).
|
||||
# File-Re-Download fuer Anhaenge braucht Platz fuer base64-
|
||||
# inflate (~1.33×). Groessere Files lehnt der file_request-
|
||||
# Handler proaktiv ab bevor's zur 1009-Disconnection kommt.
|
||||
async with websockets.connect(url, max_size=100 * 1024 * 1024) as ws:
|
||||
self.ws_rvs = ws
|
||||
retry_delay = 2
|
||||
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
|
||||
|
||||
# TCP-Keepalive auf dem unterliegenden Socket aktivieren —
|
||||
# damit NAT-Tabellen-Verfall oder "halb-tote" Verbindungen
|
||||
# (kein RST, kein FIN) innerhalb von ~1 Minute erkannt
|
||||
# werden statt nach Linux-Default (2h idle). Ohne das
|
||||
# hat die Bridge schon mal 5+h auf einer toten Connection
|
||||
# gehangen ohne dass irgendeine Exception kam.
|
||||
_enable_tcp_keepalive(ws)
|
||||
|
||||
# Heartbeat-Watchdog: jeden erfolgreichen Ping markieren wir
|
||||
# in _last_heartbeat_ok. Ein separater Watchdog killt die
|
||||
# WS-Verbindung wenn diese Marke > 60s stale ist — schuetzt
|
||||
# gegen den Fall dass ws.ping() selbst nie zurueckkommt.
|
||||
self._last_heartbeat_ok = time.monotonic()
|
||||
|
||||
# Aktuellen Modus broadcasten damit gerade verbundene Apps/Diagnostic
|
||||
# ihren UI-State sofort syncen koennen
|
||||
await self._broadcast_current_mode()
|
||||
@@ -1496,19 +1603,32 @@ class ARIABridge:
|
||||
|
||||
# Heartbeat senden (RVS erwartet Ping alle 30s)
|
||||
heartbeat_task = asyncio.create_task(self._rvs_heartbeat())
|
||||
watchdog_task = asyncio.create_task(self._rvs_heartbeat_watchdog())
|
||||
|
||||
try:
|
||||
async for raw_message in ws:
|
||||
await self._handle_rvs_message(raw_message)
|
||||
finally:
|
||||
heartbeat_task.cancel()
|
||||
watchdog_task.cancel()
|
||||
|
||||
except websockets.ConnectionClosed:
|
||||
logger.warning("[rvs] Verbindung verloren")
|
||||
# Bei Reconnect wieder primary (wss://) versuchen — die
|
||||
# Bedingungen die zum Fallback gefuehrt haben sind transient
|
||||
# (z.B. Caddy noch nicht fertig mit ACME).
|
||||
if using_fallback:
|
||||
logger.info("[rvs] Reset auf primary URL fuer Reconnect-Versuch")
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
except ConnectionRefusedError:
|
||||
logger.warning("[rvs] Nicht erreichbar")
|
||||
if using_fallback:
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
except (ssl.SSLError, OSError) as e:
|
||||
# TLS-Fehler — Fallback auf ws:// versuchen
|
||||
# TLS-Fehler — Fallback auf ws:// nur einmal pro Connect-Versuch,
|
||||
# bei naechstem Reconnect wieder primary probieren.
|
||||
if not using_fallback and self.rvs_url_fallback:
|
||||
logger.warning("[rvs] TLS-Fehler: %s", e)
|
||||
logger.warning("[rvs] TLS gewollt aber nicht verfuegbar — Fallback auf ws://")
|
||||
@@ -1517,8 +1637,17 @@ class ARIABridge:
|
||||
retry_delay = 1 # Sofort versuchen
|
||||
else:
|
||||
logger.error("[rvs] SSL-Fehler (kein Fallback): %s", e)
|
||||
except Exception:
|
||||
# Auch hier: nach gescheitertem Fallback wieder primary probieren
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
except Exception as e:
|
||||
logger.exception("[rvs] WebSocket-Fehler")
|
||||
# InvalidMessage (HTTP 400 von TLS-Endpoint bei ws-Connect)
|
||||
# → wir kleben auf dem falschen Fallback, zurueck zu primary.
|
||||
if using_fallback:
|
||||
logger.warning("[rvs] Fallback liefert auch nichts — schalte zurueck auf primary")
|
||||
current_url = self.rvs_url
|
||||
using_fallback = False
|
||||
finally:
|
||||
self.ws_rvs = None
|
||||
|
||||
@@ -1528,7 +1657,12 @@ class ARIABridge:
|
||||
retry_delay = min(retry_delay * 2, 30)
|
||||
|
||||
async def _rvs_heartbeat(self) -> None:
|
||||
"""Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt."""
|
||||
"""Sendet Heartbeats + WebSocket Pings an den RVS damit die Verbindung offen bleibt.
|
||||
|
||||
Markiert nach jedem erfolgreichen Ping `_last_heartbeat_ok` —
|
||||
`_rvs_heartbeat_watchdog` schaut darauf und killt die Verbindung
|
||||
wenn die Marke stale ist (Fallback fuer den Fall dass ping() selbst
|
||||
in einer halb-toten TCP-Verbindung ewig blockt)."""
|
||||
while True:
|
||||
await asyncio.sleep(15)
|
||||
if self.ws_rvs:
|
||||
@@ -1536,6 +1670,8 @@ class ARIABridge:
|
||||
# WebSocket Protocol-Level Ping (haelt TCP-Verbindung am Leben)
|
||||
pong = await self.ws_rvs.ping()
|
||||
await asyncio.wait_for(pong, timeout=10)
|
||||
# Erfolgreicher Pong → Watchdog-Marke updaten
|
||||
self._last_heartbeat_ok = time.monotonic()
|
||||
except Exception:
|
||||
logger.warning("[rvs] Ping fehlgeschlagen — Verbindung tot, erzwinge Reconnect")
|
||||
try:
|
||||
@@ -1552,6 +1688,45 @@ class ARIABridge:
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# Heartbeat-Watchdog: wenn der letzte erfolgreiche Ping > HEARTBEAT_STALE_SEC
|
||||
# her ist (z.B. weil ws.ping() im Limbo haengt), erzwingen wir ein hartes
|
||||
# Schliessen der Verbindung. Das wirft den `async for raw_message in ws`-
|
||||
# Loop aus, der Reconnect-Loop in connect_to_rvs greift dann.
|
||||
HEARTBEAT_STALE_SEC = 60.0
|
||||
HEARTBEAT_WATCHDOG_INTERVAL_SEC = 20.0
|
||||
|
||||
async def _rvs_heartbeat_watchdog(self) -> None:
|
||||
"""Independent watchdog der den Heartbeat-Status ueberwacht und
|
||||
bei staleness die WS-Verbindung haert killt. Wird parallel zu
|
||||
`_rvs_heartbeat` gestartet, ist aber unabhaengig davon — auch wenn
|
||||
die heartbeat-Coroutine in einem await ewig haengen wuerde, laeuft
|
||||
diese hier weiter (eigene Coroutine, eigener await-Slot)."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.HEARTBEAT_WATCHDOG_INTERVAL_SEC)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self.ws_rvs:
|
||||
return
|
||||
stale = time.monotonic() - getattr(self, "_last_heartbeat_ok", time.monotonic())
|
||||
if stale > self.HEARTBEAT_STALE_SEC:
|
||||
logger.error(
|
||||
"[rvs] Heartbeat stale (%.0fs > %.0fs) — erzwinge harten Reconnect",
|
||||
stale, self.HEARTBEAT_STALE_SEC,
|
||||
)
|
||||
ws = self.ws_rvs
|
||||
self.ws_rvs = None
|
||||
try:
|
||||
# close mit Reason — falls's hängt killen wir via Underlying-Transport
|
||||
await asyncio.wait_for(ws.close(code=1011, reason="heartbeat-stale"), timeout=3.0)
|
||||
except Exception:
|
||||
# Letzte Option: Transport direkt schliessen, das wirft den recv-Loop
|
||||
try:
|
||||
ws.transport.close() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
|
||||
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
|
||||
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
|
||||
@@ -1623,11 +1798,23 @@ class ARIABridge:
|
||||
self._next_voice_override = voice_override or None
|
||||
logger.info("[rvs] Voice fuer Antworten: %s",
|
||||
self._next_voice_override or "(Default)")
|
||||
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet)
|
||||
# Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet) —
|
||||
# plus persistente Spiegelung damit der Wert nach Bridge-Restart
|
||||
# erhalten bleibt und Diagnostic-Chats / Trigger-Replies den
|
||||
# zuletzt von der App gesetzten Speed bekommen.
|
||||
if "speed" in payload:
|
||||
try:
|
||||
speed = float(payload.get("speed", 0) or 0)
|
||||
self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None
|
||||
if 0.1 <= speed <= 5.0:
|
||||
self._next_speed_override = speed
|
||||
# Persistieren wenn der Wert sich gegenueber dem
|
||||
# gespeicherten geaendert hat — vermeidet voice_config.json
|
||||
# auf jeder Nachricht zu schreiben.
|
||||
if speed != getattr(self, "_persistent_xtts_speed", None):
|
||||
self._persistent_xtts_speed = speed
|
||||
asyncio.create_task(self._persist_speed_change(speed))
|
||||
else:
|
||||
self._next_speed_override = None
|
||||
except (TypeError, ValueError):
|
||||
self._next_speed_override = None
|
||||
if text:
|
||||
@@ -1661,8 +1848,14 @@ class ARIABridge:
|
||||
return
|
||||
|
||||
if msg_type == "cancel_request":
|
||||
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||
await self._cancel_via_diagnostic()
|
||||
hard = bool(payload.get("hard"))
|
||||
if hard:
|
||||
logger.warning("[rvs] NOT-AUS — hard cancel: Diagnostic /api/cancel + Proxy /cancel-all")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._cancel_proxy_subprocesses()
|
||||
else:
|
||||
logger.info("[rvs] Cancel-Request von App — rufe Diagnostic /api/cancel auf")
|
||||
await self._cancel_via_diagnostic()
|
||||
await self._emit_activity("idle", "")
|
||||
return
|
||||
|
||||
@@ -1750,6 +1943,15 @@ class ARIABridge:
|
||||
self.xtts_voice = payload["xttsVoice"]
|
||||
logger.info("[rvs] XTTS-Stimme: %s", self.xtts_voice or "default")
|
||||
changed = True
|
||||
if "xttsSpeed" in payload:
|
||||
try:
|
||||
new_speed = float(payload["xttsSpeed"])
|
||||
if 0.1 <= new_speed <= 5.0:
|
||||
self._persistent_xtts_speed = new_speed
|
||||
logger.info("[rvs] XTTS-Speed (persistent): %.2fx", new_speed)
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "whisperModel" in payload:
|
||||
new_model = payload["whisperModel"]
|
||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||
@@ -1767,6 +1969,15 @@ class ARIABridge:
|
||||
self._f5tts_config = {}
|
||||
self._f5tts_config[k] = payload[k]
|
||||
changed = True
|
||||
# FLUX-Felder: gleiche Logik wie F5-TTS. flux-bridge applied
|
||||
# fluxDefaultModel selbst (Pipeline-Swap). Keywords nutzt Brain
|
||||
# via /shared/config/voice_config.json.
|
||||
for k in ("fluxDefaultModel", "fluxKeywordRaw", "fluxKeywordSwitch", "huggingfaceToken"):
|
||||
if k in payload:
|
||||
if not hasattr(self, "_flux_config"):
|
||||
self._flux_config = {}
|
||||
self._flux_config[k] = payload[k]
|
||||
changed = True
|
||||
# Persistent speichern in Shared Volume
|
||||
if changed:
|
||||
try:
|
||||
@@ -1776,7 +1987,10 @@ class ARIABridge:
|
||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||
"whisperModel": self.stt_engine.model_size,
|
||||
}
|
||||
if getattr(self, "_persistent_xtts_speed", None) is not None:
|
||||
config_data["xttsSpeed"] = self._persistent_xtts_speed
|
||||
config_data.update(getattr(self, "_f5tts_config", {}))
|
||||
config_data.update(getattr(self, "_flux_config", {}))
|
||||
with open("/shared/config/voice_config.json", "w") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
|
||||
@@ -2204,6 +2418,33 @@ class ARIABridge:
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
return
|
||||
# Groessen-Check VOR base64-Encode + Send. Sonst zerreisst's bei
|
||||
# grossen Files (>~70 MB binaer) die WebSocket-Verbindung mit
|
||||
# Code 1009 (message too big) — RVS-Server droppt, Bridge crasht
|
||||
# im cleanup (websockets-Lib-Bug). Limit deckt typische Videos
|
||||
# und Bilder ab; alles drueber soll der User per SSH abholen.
|
||||
FILE_MAX_BYTES = 70 * 1024 * 1024
|
||||
try:
|
||||
file_size = os.path.getsize(server_path)
|
||||
except OSError as exc:
|
||||
logger.warning("[rvs] getsize fehlgeschlagen: %s", exc)
|
||||
file_size = 0
|
||||
if file_size > FILE_MAX_BYTES:
|
||||
logger.warning("[rvs] Re-Download abgelehnt: %s zu gross (%dMB > %dMB)",
|
||||
server_path, file_size // (1024 * 1024),
|
||||
FILE_MAX_BYTES // (1024 * 1024))
|
||||
await self._send_to_rvs({
|
||||
"type": "file_response",
|
||||
"payload": {
|
||||
"requestId": req_id,
|
||||
"serverPath": server_path,
|
||||
"name": os.path.basename(server_path),
|
||||
"error": f"Datei zu gross fuer Transfer ({file_size // (1024 * 1024)} MB, Limit {FILE_MAX_BYTES // (1024 * 1024)} MB)",
|
||||
"sizeBytes": file_size,
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
return
|
||||
with open(server_path, "rb") as f:
|
||||
file_b64 = base64.b64encode(f.read()).decode("ascii")
|
||||
mime, _ = mimetypes.guess_type(server_path)
|
||||
@@ -2279,8 +2520,96 @@ class ARIABridge:
|
||||
future.set_result(text)
|
||||
return
|
||||
|
||||
elif msg_type == "stt_endpoint":
|
||||
# Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus
|
||||
# einen Endpoint erkannt und schickt den finalen Text direkt.
|
||||
# Wir uebernehmen die Rolle die sonst _process_app_audio NACH dem
|
||||
# STT-Schritt hat: STT-Text fuer UI broadcasten + send_to_core.
|
||||
# Kein Audio-Roundtrip mehr — App-Latenz sinkt deutlich.
|
||||
text = (payload.get("text") or "").strip()
|
||||
if not text:
|
||||
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
|
||||
payload.get("reason", ""))
|
||||
return
|
||||
audio_request_id = payload.get("audioRequestId", "") or ""
|
||||
voice = payload.get("voice", "") or ""
|
||||
speed_raw = payload.get("speed")
|
||||
interrupted = bool(payload.get("interrupted", False))
|
||||
location = payload.get("location") or None
|
||||
|
||||
# Voice-Override fuer Folgenachrichten — gleiche Semantik wie beim
|
||||
# 'audio'-Event. Nur setzen wenn vom App-Stream mitgegeben.
|
||||
if voice:
|
||||
self._next_voice_override = voice or None
|
||||
logger.info("[rvs] Voice fuer Antworten (via stt_endpoint): %s",
|
||||
self._next_voice_override or "(Default)")
|
||||
if speed_raw is not None:
|
||||
try:
|
||||
sp = float(speed_raw)
|
||||
self._next_speed_override = sp if 0.1 <= sp <= 5.0 else None
|
||||
except (TypeError, ValueError):
|
||||
self._next_speed_override = None
|
||||
|
||||
# State-Persist wie bei _process_app_audio
|
||||
self._persist_location(location)
|
||||
self._persist_user_activity()
|
||||
|
||||
logger.info("[rvs] stt_endpoint: '%s' (%dms, reason=%s)%s%s reqId=%s",
|
||||
text[:80],
|
||||
payload.get("sttMs", 0),
|
||||
payload.get("reason", ""),
|
||||
" [BARGE-IN]" if interrupted else "",
|
||||
" [GPS]" if location else "",
|
||||
audio_request_id[:16] if audio_request_id else "?")
|
||||
|
||||
# Idempotenz ueber audioRequestId — falls App den Stream irgendwie
|
||||
# nochmal triggern sollte (Reconnect-Race etc.).
|
||||
client_msg_id = audio_request_id or None
|
||||
if self._is_duplicate_client_msg(client_msg_id):
|
||||
return
|
||||
|
||||
asyncio.create_task(self._process_endpoint_text(
|
||||
text, interrupted, audio_request_id, location,
|
||||
client_msg_id=client_msg_id))
|
||||
return
|
||||
|
||||
elif msg_type == "oauth_callback":
|
||||
# RVS hat einen OAuth-Provider-Callback empfangen (z.B. Spotify
|
||||
# nach User-Authorize) und broadcastet ihn. Wir forwarden an Brain,
|
||||
# das den state-Match macht + code gegen access_token tauscht.
|
||||
asyncio.create_task(self._forward_oauth_callback(payload))
|
||||
return
|
||||
|
||||
elif msg_type == "flux_response":
|
||||
# Antwort der flux-bridge auf unseren flux_request. Erste Nachricht
|
||||
# mit state='rendering' ist nur Progress-Ping — die echte Antwort
|
||||
# kommt mit state='done' (oder error).
|
||||
request_id = payload.get("requestId", "")
|
||||
future = self._pending_flux.get(request_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
error = payload.get("error", "")
|
||||
if error:
|
||||
logger.warning("[rvs] flux_response Fehler: %s", error)
|
||||
future.set_result({"error": error})
|
||||
return
|
||||
state = payload.get("state", "")
|
||||
if state == "rendering":
|
||||
# Nur Progress-Info, future bleibt offen
|
||||
logger.info("[rvs] flux: rendering %dx%d steps=%d ...",
|
||||
payload.get("width", 0), payload.get("height", 0),
|
||||
payload.get("steps", 0))
|
||||
return
|
||||
# state == "done" oder fehlt → final
|
||||
logger.info("[rvs] flux fertig: %dx%d, %.1fs, %d KB",
|
||||
payload.get("width", 0), payload.get("height", 0),
|
||||
payload.get("renderSeconds", 0),
|
||||
(payload.get("sizeBytes", 0)) // 1024)
|
||||
future.set_result(payload)
|
||||
return
|
||||
|
||||
elif msg_type == "service_status":
|
||||
# Gamebox-Bridges (whisper / f5tts) melden ihren Lade-Status.
|
||||
# Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
|
||||
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
|
||||
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
|
||||
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
|
||||
@@ -2291,6 +2620,11 @@ class ARIABridge:
|
||||
self._remote_stt_ready = (state == "ready")
|
||||
if self._remote_stt_ready != was_ready:
|
||||
logger.info("[rvs] whisper-bridge -> %s", state)
|
||||
elif svc == "flux":
|
||||
was_ready = self._remote_flux_ready
|
||||
self._remote_flux_ready = (state == "ready")
|
||||
if self._remote_flux_ready != was_ready:
|
||||
logger.info("[rvs] flux-bridge -> %s", state)
|
||||
return
|
||||
|
||||
elif msg_type == "config_request":
|
||||
@@ -2381,6 +2715,44 @@ class ARIABridge:
|
||||
else:
|
||||
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
|
||||
|
||||
async def _process_endpoint_text(self, text: str,
|
||||
interrupted: bool = False,
|
||||
audio_request_id: str = "",
|
||||
location: Optional[dict] = None,
|
||||
client_msg_id: Optional[str] = None) -> None:
|
||||
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
||||
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
||||
|
||||
Spiegel-Methode zu _process_app_audio NACH dem STT-Schritt. Bewusst
|
||||
eigene Methode statt Code-Pfade in _process_app_audio aufdroeseln,
|
||||
damit der Legacy-Pfad (App schickt 'audio') unangetastet bleibt.
|
||||
"""
|
||||
try:
|
||||
stt_payload = {
|
||||
"text": text,
|
||||
"sender": "stt",
|
||||
}
|
||||
if audio_request_id:
|
||||
stt_payload["audioRequestId"] = audio_request_id
|
||||
if location:
|
||||
stt_payload["location"] = location
|
||||
ok = await self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": stt_payload,
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
if ok:
|
||||
logger.info("[rvs] STT-Text (endpoint) broadcastet")
|
||||
else:
|
||||
logger.warning("[rvs] STT-Text (endpoint) NICHT broadcastet")
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||
|
||||
core_text = self._build_core_text(text, interrupted, location)
|
||||
await self.send_to_core(core_text,
|
||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||
client_msg_id=client_msg_id)
|
||||
|
||||
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
|
||||
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
|
||||
|
||||
@@ -2475,6 +2847,105 @@ class ARIABridge:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ── Flux-Roundtrip: Brain → Bridge → RVS → flux-bridge → zurueck ──
|
||||
# FLUX-Render auf der 3060 dauert je nach Aufloesung/Steps 20-90 s.
|
||||
# Beim 1. Render frisch nach Container-Start muss zudem das ~24 GB
|
||||
# Modell von HF geladen werden — daher der grosse Loading-Timeout.
|
||||
_FLUX_TIMEOUT_READY_S = 240.0 # 4 min nach erstem Render
|
||||
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
|
||||
|
||||
async def _flux_generate(self, prompt: str, width: int, height: int,
|
||||
steps: Optional[int], guidance: Optional[float],
|
||||
seed: Optional[int], model: Optional[str] = None) -> dict:
|
||||
"""Schickt einen flux_request an die flux-bridge, wartet auf das fertige
|
||||
PNG, speichert es nach /shared/uploads/aria_generated_<ts>.png.
|
||||
|
||||
Rueckgabe:
|
||||
{ok: True, path, sizeBytes, width, height, steps, guidance, seed, model, renderSeconds}
|
||||
{ok: False, error}
|
||||
"""
|
||||
if self.ws_rvs is None:
|
||||
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
self._pending_flux[request_id] = future
|
||||
|
||||
try:
|
||||
req_payload: dict = {"requestId": request_id, "prompt": prompt,
|
||||
"width": width, "height": height}
|
||||
if steps is not None:
|
||||
req_payload["steps"] = steps
|
||||
if guidance is not None:
|
||||
req_payload["guidance_scale"] = guidance
|
||||
if seed is not None:
|
||||
req_payload["seed"] = seed
|
||||
if model:
|
||||
# 'dev' | 'schnell' — flux-bridge mappt das auf HF-IDs.
|
||||
# Ohne Angabe nimmt die flux-bridge ihren konfigurierten Default.
|
||||
req_payload["model"] = model
|
||||
|
||||
logger.info("[rvs] flux_request → flux-bridge (id=%s, %dx%d, steps=%s, model=%s, prompt=%r)",
|
||||
request_id[:8], width, height, steps, model or "default", prompt[:60])
|
||||
ok = await self._send_to_rvs({
|
||||
"type": "flux_request",
|
||||
"payload": req_payload,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
if not ok:
|
||||
return {"ok": False, "error": "flux_request konnte nicht gesendet werden"}
|
||||
|
||||
timeout_s = (self._FLUX_TIMEOUT_READY_S
|
||||
if self._remote_flux_ready
|
||||
else self._FLUX_TIMEOUT_LOADING_S)
|
||||
result = await asyncio.wait_for(future, timeout=timeout_s)
|
||||
|
||||
if not isinstance(result, dict) or result.get("error"):
|
||||
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
|
||||
return {"ok": False, "error": err or "flux-bridge Fehler"}
|
||||
|
||||
b64 = result.get("base64") or ""
|
||||
if not b64:
|
||||
return {"ok": False, "error": "flux_response ohne Bilddaten"}
|
||||
|
||||
try:
|
||||
png_bytes = base64.b64decode(b64)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"PNG-Decode fehlgeschlagen: {e}"}
|
||||
|
||||
SHARED_DIR = "/shared/uploads"
|
||||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||||
ts_ms = int(time.time() * 1000)
|
||||
file_name = f"aria_generated_{ts_ms}.png"
|
||||
path = os.path.join(SHARED_DIR, file_name)
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Speichern fehlgeschlagen: {e}"}
|
||||
|
||||
logger.info("[rvs] flux PNG gespeichert: %s (%d KB)", path, len(png_bytes) // 1024)
|
||||
return {
|
||||
"ok": True,
|
||||
"path": path,
|
||||
"sizeBytes": len(png_bytes),
|
||||
"width": result.get("width", width),
|
||||
"height": result.get("height", height),
|
||||
"steps": result.get("steps"),
|
||||
"guidance": result.get("guidance"),
|
||||
"seed": result.get("seed"),
|
||||
"model": result.get("model", ""),
|
||||
"renderSeconds": result.get("renderSeconds", 0),
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": f"Render-Timeout ({int(timeout_s)}s) — flux-bridge offline?"}
|
||||
except Exception as e:
|
||||
logger.exception("[rvs] _flux_generate Fehler")
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
finally:
|
||||
self._pending_flux.pop(request_id, None)
|
||||
|
||||
async def _send_to_rvs(self, message: dict) -> bool:
|
||||
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check.
|
||||
|
||||
@@ -2524,6 +2995,50 @@ class ARIABridge:
|
||||
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||
|
||||
async def _forward_oauth_callback(self, payload: dict) -> None:
|
||||
"""Forwarded den OAuth-Callback (kommt via RVS vom RVS-HTTP-Handler)
|
||||
per HTTP an Brain. Brain hat den pending-state + macht den token-
|
||||
exchange. Fire-and-forget — bei Failure loggen wir nur."""
|
||||
service = (payload.get("service") or "").strip()
|
||||
if not service:
|
||||
logger.warning("[oauth] callback ohne service, ignoriert")
|
||||
return
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
url = f"{brain_url}/internal/oauth-callback"
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.info("[oauth] Forward %s → brain: %s %s", service, status, body)
|
||||
|
||||
async def _cancel_proxy_subprocesses(self) -> None:
|
||||
"""Not-Aus: ruft den proxy-internen /cancel-all Side-Channel auf
|
||||
(siehe proxy-patches/routes.js). Killt alle aktiven Claude-Code-
|
||||
Subprocesses sofort. Bridge ist auf aria-net, Proxy auch — also
|
||||
per Container-Name + Side-Channel-Port (Default 3457) erreichbar."""
|
||||
url = os.environ.get("PROXY_INTERNAL_URL", "http://aria-proxy:3457") + "/cancel-all"
|
||||
|
||||
def _do_request():
|
||||
try:
|
||||
req = urllib.request.Request(url, method="POST", data=b"")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "ignore")[:200]
|
||||
except Exception as e:
|
||||
return f"error: {e}", ""
|
||||
|
||||
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||
logger.warning("[NOT-AUS] proxy /cancel-all: %s %s", status, body)
|
||||
|
||||
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
|
||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||
|
||||
@@ -2705,6 +3220,61 @@ class ARIABridge:
|
||||
# selbst wenn derselbe Name zweimal in Folge kommt.
|
||||
asyncio.create_task(self._emit_activity("tool", tool, force=True))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/agent-stream":
|
||||
# Vom Proxy gefeuert: voller Live-Stream der Claude-Code-
|
||||
# Session (assistant_text, tool_use mit Input, tool_result
|
||||
# mit truncated Output, start/end Markers). Wir leiten 1:1
|
||||
# als RVS agent_stream an Diagnostic (ARIA-Live-View) und
|
||||
# App weiter — read-only Mirror der gerade laufenden
|
||||
# ARIA-Aktivitaet.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "agent_stream",
|
||||
"payload": data,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}))
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/flux-generate":
|
||||
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
|
||||
# Render-Request via RVS an die flux-bridge (Gamebox),
|
||||
# warten synchron auf die PNG-Antwort, speichern sie nach
|
||||
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
|
||||
# Brain referenziert das Bild dann mit [FILE:]-Marker in
|
||||
# seiner Antwort, die Bridge broadcastet daraufhin
|
||||
# automatisch ein file_from_aria-Event an App+Diagnostic.
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
prompt = (data.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
await _send_response(writer, 400, {"error": "prompt erforderlich"})
|
||||
return
|
||||
try:
|
||||
width = int(data.get("width") or 1024)
|
||||
height = int(data.get("height") or 1024)
|
||||
except (TypeError, ValueError):
|
||||
width, height = 1024, 1024
|
||||
steps_raw = data.get("steps")
|
||||
guidance_raw = data.get("guidance_scale")
|
||||
seed_raw = data.get("seed")
|
||||
steps = int(steps_raw) if isinstance(steps_raw, (int, float)) else None
|
||||
guidance = float(guidance_raw) if isinstance(guidance_raw, (int, float)) else None
|
||||
seed = int(seed_raw) if isinstance(seed_raw, (int, float)) else None
|
||||
model_raw = data.get("model")
|
||||
model = model_raw.strip() if isinstance(model_raw, str) and model_raw.strip() in ("dev", "schnell") else None
|
||||
|
||||
result = await self._flux_generate(
|
||||
prompt=prompt, width=width, height=height,
|
||||
steps=steps, guidance=guidance, seed=seed, model=model,
|
||||
)
|
||||
status = 200 if result.get("ok") else 502
|
||||
await _send_response(writer, status, result)
|
||||
elif method == "POST" and path == "/internal/delete-chat-message":
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
@@ -2923,6 +3493,51 @@ class ARIABridge:
|
||||
self.running = False
|
||||
|
||||
|
||||
# ── File-Based Liveness Watchdog ─────────────────────────────
|
||||
#
|
||||
# Separater OS-Thread (NICHT asyncio) — schreibt periodisch eine
|
||||
# Liveness-Datei mit aktuellem Timestamp und prüft ob der asyncio-Loop
|
||||
# noch lebt. Wenn ueber LIVENESS_SELFKILL_SEC keine erfolgreiche Heart-
|
||||
# beat-Bestätigung vom RVS kam, killt der Watchdog den ganzen Prozess
|
||||
# (os._exit). Docker restart-Policy startet neu. Last-Resort fuer den
|
||||
# Fall dass weder TCP-Keepalive noch der asyncio-Heartbeat-Watchdog
|
||||
# greifen — z.B. wenn der event loop selbst korrumpiert ist.
|
||||
|
||||
LIVENESS_FILE = Path("/shared/health/bridge_alive")
|
||||
LIVENESS_CHECK_INTERVAL_SEC = 15
|
||||
LIVENESS_SELFKILL_SEC = 180 # 3 min — alle anderen Watchdogs (TCP-Keepalive
|
||||
# ~1 min, asyncio-Watchdog 60s) sollten vorher
|
||||
# greifen. Wenn nicht, ist der Prozess wirklich
|
||||
# kaputt.
|
||||
|
||||
|
||||
def _liveness_watchdog(bridge: "ARIABridge") -> None:
|
||||
try:
|
||||
LIVENESS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
time.sleep(LIVENESS_CHECK_INTERVAL_SEC)
|
||||
# 1) Timestamp schreiben — externe Watcher koennen das pollen
|
||||
try:
|
||||
LIVENESS_FILE.write_text(str(int(time.time())))
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Letzten heartbeat checken (wird vom asyncio-Loop gesetzt). Wenn
|
||||
# zu lange stale → Self-Kill. Docker-restart-Policy uebernimmt.
|
||||
last_ok = getattr(bridge, "_last_heartbeat_ok", None)
|
||||
if last_ok is None:
|
||||
continue # noch keine RVS-Verbindung gewesen, fair, kein Kill
|
||||
stale = time.monotonic() - last_ok
|
||||
if stale > LIVENESS_SELFKILL_SEC:
|
||||
sys.stderr.write(
|
||||
f"[liveness] heartbeat {int(stale)}s stale — Self-Kill "
|
||||
f"(Docker restart_policy uebernimmt)\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
os._exit(1)
|
||||
|
||||
|
||||
# ── Hauptprogramm ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2946,6 +3561,12 @@ def main() -> None:
|
||||
logger.exception("Initialisierung fehlgeschlagen")
|
||||
sys.exit(1)
|
||||
|
||||
# Liveness-Watchdog als daemon-Thread starten (immune gegen asyncio-Hangs)
|
||||
threading.Thread(target=_liveness_watchdog, args=(bridge,),
|
||||
daemon=True, name="liveness-watchdog").start()
|
||||
logger.info("[liveness] Watchdog-Thread gestartet (selfkill nach %ds Heartbeat-Staleness)",
|
||||
LIVENESS_SELFKILL_SEC)
|
||||
|
||||
# Event-Loop starten
|
||||
try:
|
||||
asyncio.run(bridge.run())
|
||||
|
||||
+845
-153
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 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 ───────────────────────────────────────────────
|
||||
const state = {
|
||||
gateway: { status: "disconnected", lastError: null, handshakeOk: false },
|
||||
@@ -492,9 +526,10 @@ function handleGatewayMessage(msg) {
|
||||
}
|
||||
|
||||
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) {
|
||||
log("error", "gateway", "Nicht verbunden — kann nicht senden");
|
||||
if (isTrace) traceEnd(false, "Gateway nicht verbunden");
|
||||
if (isTrace) traceEnd(false, "Gateway deprecated — nutze RVS");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -632,6 +667,14 @@ function connectRVS(forcePlain) {
|
||||
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") {
|
||||
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
||||
const m = msg.payload || {};
|
||||
@@ -695,8 +738,16 @@ function connectRVS(forcePlain) {
|
||||
state.rvs.lastError = err.message;
|
||||
broadcastState();
|
||||
|
||||
// TLS Fallback
|
||||
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) {
|
||||
// TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
|
||||
// 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;
|
||||
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
|
||||
try { ws.removeAllListeners(); ws.close(); } catch (_) {}
|
||||
@@ -757,22 +808,20 @@ function sendToRVS_raw(msgObj) {
|
||||
}
|
||||
|
||||
function sendToRVS(text, isTrace) {
|
||||
// Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit
|
||||
// Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig,
|
||||
// aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme.
|
||||
// Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige.
|
||||
|
||||
// 1. An Gateway senden (damit ARIA antwortet)
|
||||
const gatewayOk = sendToGateway(text, isTrace);
|
||||
|
||||
// 2. An RVS senden (damit die App die Nachricht sieht)
|
||||
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
|
||||
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
|
||||
// den Text als User-Nachricht ans Brain weiterleitet und die App +
|
||||
// Diagnostic die Bubble live spiegeln koennen.
|
||||
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
|
||||
if (isTrace) traceEnd(false, "RVS nicht verbunden");
|
||||
return false;
|
||||
}
|
||||
sendToRVS_raw({
|
||||
type: "chat",
|
||||
payload: { text, sender: "diagnostic" },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return gatewayOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Claude Proxy Test ────────────────────────────────────
|
||||
@@ -1457,7 +1506,12 @@ const server = http.createServer((req, res) => {
|
||||
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;
|
||||
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
|
||||
@@ -1702,6 +1756,68 @@ const server = http.createServer((req, res) => {
|
||||
});
|
||||
req.pipe(proxyReq);
|
||||
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") {
|
||||
// Komplettes Gehirn als tar.gz streamen.
|
||||
// Schritte: Brain + Qdrant stoppen (saubere Bytes) → tar streamen → wieder starten.
|
||||
@@ -1836,8 +1952,11 @@ wss.on("connection", (ws) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
|
||||
if (msg.action === "test_gateway") {
|
||||
traceStart("Gateway", msg.text || "aria lebst du noch?");
|
||||
sendToGateway(msg.text || "aria lebst du noch?", true);
|
||||
// Deprecated — Gateway-Pfad ist raus. Wir leiten an RVS um damit
|
||||
// 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") {
|
||||
traceStart("RVS", msg.text || "aria lebst du noch?");
|
||||
sendToRVS(msg.text || "aria lebst du noch?", true);
|
||||
@@ -1885,6 +2004,18 @@ wss.on("connection", (ws) => {
|
||||
if (traceActive) traceEnd(false, "Vom Benutzer abgebrochen");
|
||||
broadcast({ type: "agent_activity", activity: "idle" });
|
||||
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") {
|
||||
// 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...`);
|
||||
@@ -1943,6 +2074,26 @@ wss.on("connection", (ws) => {
|
||||
if (msg.f5ttsNfeStep !== undefined && !isNaN(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 {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2));
|
||||
|
||||
+21
-8
@@ -12,7 +12,7 @@ services:
|
||||
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/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
|
||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$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/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
volumes:
|
||||
- ~/.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-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)
|
||||
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
|
||||
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
|
||||
@@ -67,11 +67,27 @@ services:
|
||||
- QDRANT_PORT=6333
|
||||
- PROXY_URL=http://proxy:3456
|
||||
- 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:
|
||||
- ./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/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
|
||||
networks:
|
||||
- aria-net
|
||||
@@ -87,7 +103,7 @@ services:
|
||||
ports:
|
||||
- "3001:3001" # Diagnostic Web-UI (Diagnostic teilt Netzwerk mit Bridge)
|
||||
volumes:
|
||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch
|
||||
- ./aria-shared:/shared # Shared Volume fuer Datei-Austausch
|
||||
# Audio-Zugriff
|
||||
- /run/user/1000/pulse:/run/user/1000/pulse
|
||||
- /dev/snd:/dev/snd
|
||||
@@ -116,7 +132,7 @@ services:
|
||||
volumes:
|
||||
- /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-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)
|
||||
environment:
|
||||
- ARIA_AUTH_TOKEN=${ARIA_AUTH_TOKEN:-}
|
||||
@@ -129,9 +145,6 @@ services:
|
||||
- RVS_TOKEN=${RVS_TOKEN:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
aria-shared: # Datei-Austausch zwischen Bridge / Brain / Diagnostic
|
||||
|
||||
networks:
|
||||
aria-net:
|
||||
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
|
||||
@@ -377,6 +377,20 @@ Skills mit Tool-Use.
|
||||
- [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
|
||||
|
||||
### App Features
|
||||
@@ -389,3 +403,4 @@ Skills mit Tool-Use.
|
||||
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
|
||||
- [ ] Heartbeat (periodische Selbst-Checks)
|
||||
- [ ] 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
|
||||
|
||||
+245
-23
@@ -7,6 +7,10 @@
|
||||
* (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.
|
||||
*
|
||||
@@ -21,42 +25,180 @@ 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);
|
||||
|
||||
/**
|
||||
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
|
||||
* keine Fehler nach oben. Logged Fehler still.
|
||||
* Generic Fire-and-forget POST an die Bridge. Keine Awaits, keine Fehler
|
||||
* nach oben. Eingesetzt fuer Tool-Hook + Stream-Hook.
|
||||
*/
|
||||
function _emitToolEvent(toolName) {
|
||||
if (!toolName) return;
|
||||
function _postJson(url, body) {
|
||||
try {
|
||||
const u = new URL(TOOL_HOOK_URL);
|
||||
const body = JSON.stringify({ tool: String(toolName) });
|
||||
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(body) },
|
||||
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(body);
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (_) { /* niemals weiterwerfen */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
|
||||
* kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
|
||||
* Pusht einen Tool-Use-Event an die Bridge (alter Gedanken-Stream-Pfad).
|
||||
*/
|
||||
function _attachToolHook(subprocess) {
|
||||
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 && b.type === "tool_use" && b.name) {
|
||||
_emitToolEvent(b.name);
|
||||
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 */ }
|
||||
@@ -86,9 +228,17 @@ export async function handleChatCompletions(req, res) {
|
||||
// Convert to CLI input format
|
||||
const cliInput = openaiToCli(body);
|
||||
const subprocess = new ClaudeSubprocess();
|
||||
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
|
||||
// Greift fuer beide Branches (stream + non-stream).
|
||||
_attachToolHook(subprocess);
|
||||
// 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);
|
||||
}
|
||||
@@ -217,21 +367,42 @@ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId
|
||||
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);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: "server_error",
|
||||
code: null,
|
||||
},
|
||||
});
|
||||
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));
|
||||
}
|
||||
@@ -306,4 +477,55 @@ export function handleHealth(_req, res) {
|
||||
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:
|
||||
rvs:
|
||||
build: .
|
||||
ports:
|
||||
- "${RVS_PORT:-443}:3000"
|
||||
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:
|
||||
- ./updates:/updates # APK-Dateien fuer Auto-Update
|
||||
environment:
|
||||
- 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:
|
||||
|
||||
+136
-7
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { WebSocketServer } = require("ws");
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
@@ -39,6 +40,9 @@ const ALLOWED_TYPES = new Set([
|
||||
"stt_request", "stt_response",
|
||||
"service_status",
|
||||
"config_request",
|
||||
"flux_request", "flux_response",
|
||||
"agent_stream",
|
||||
"oauth_callback",
|
||||
]);
|
||||
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
@@ -69,20 +73,145 @@ function cleanupRooms() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket-Server starten ────────────────────────────────────────
|
||||
|
||||
// maxPayload 50MB: TTS-Streaming + Voice-Upload (WAV als base64) +
|
||||
// ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
|
||||
//
|
||||
// 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.
|
||||
// Default-Limit war der Killer fuer die voice_upload Pipeline.
|
||||
const wss = new WebSocketServer({ port: PORT, maxPayload: 50 * 1024 * 1024 });
|
||||
// Plus: file_request/file_response fuer Re-Download von Anhaengen.
|
||||
// 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", () => {
|
||||
log(`RVS läuft auf Port ${PORT} | Max Sessions: ${MAX_SESSIONS}`);
|
||||
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
|
||||
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
|
||||
const apkInfo = getLatestAPK();
|
||||
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) => {
|
||||
// Token aus URL-Query lesen: ws://host:port/?token=abc123
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
|
||||
# Laeuft auf dem Gaming-PC (RTX 3060)
|
||||
# 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:
|
||||
|
||||
@@ -912,6 +912,12 @@ async def run_loop(runner: F5Runner) -> None:
|
||||
continue
|
||||
await asyncio.sleep(min(retry_s, 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:
|
||||
|
||||
+358
-25
@@ -2,8 +2,19 @@
|
||||
"""
|
||||
ARIA Whisper Bridge — laeuft auf der Gamebox (RTX 3060).
|
||||
|
||||
Empfaengt stt_request via RVS → FFmpeg-Konvertierung → faster-whisper auf GPU
|
||||
→ sendet stt_response zurueck an die aria-bridge.
|
||||
Zwei Modi:
|
||||
|
||||
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:
|
||||
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN
|
||||
@@ -21,6 +32,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
@@ -47,6 +59,13 @@ WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
|
||||
|
||||
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:
|
||||
"""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: Optional[WhisperModel] = None
|
||||
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:
|
||||
logger.info(
|
||||
@@ -78,19 +100,21 @@ class WhisperRunner:
|
||||
loop = asyncio.get_event_loop()
|
||||
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:
|
||||
return "", 0.0
|
||||
|
||||
def _run():
|
||||
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)
|
||||
return text, info.duration
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _run)
|
||||
async with self._transcribe_lock:
|
||||
return await loop.run_in_executor(None, _run)
|
||||
|
||||
|
||||
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
|
||||
@@ -128,6 +152,14 @@ def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
|
||||
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:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
@@ -139,14 +171,284 @@ async def _send(ws, mtype: str, payload: dict) -> None:
|
||||
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# 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,
|
||||
})
|
||||
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])
|
||||
|
||||
# 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:
|
||||
request_id = payload.get("requestId", "")
|
||||
audio_b64 = payload.get("audio", "")
|
||||
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)
|
||||
language = payload.get("language") or WHISPER_LANGUAGE
|
||||
|
||||
@@ -156,8 +458,6 @@ async def handle_stt_request(ws, payload: dict, runner: WhisperRunner) -> None:
|
||||
|
||||
try:
|
||||
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
|
||||
if needs_load:
|
||||
await _broadcast_status(ws, "loading", model=model)
|
||||
@@ -205,7 +505,11 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
|
||||
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
|
||||
retry_s = 2
|
||||
tls_fallback_tried = False
|
||||
@@ -216,20 +520,12 @@ async def run_loop(runner: WhisperRunner) -> None:
|
||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||
try:
|
||||
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:
|
||||
logger.info("RVS verbunden")
|
||||
retry_s = 2
|
||||
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():
|
||||
try:
|
||||
if runner.model is not None:
|
||||
@@ -259,9 +555,41 @@ async def run_loop(runner: WhisperRunner) -> None:
|
||||
logger.info("stt_request empfangen (id=%s, %dKB Audio)",
|
||||
req_id[:8] if req_id != "?" else "?", audio_len // 1365)
|
||||
asyncio.create_task(handle_stt_request(ws, payload, runner))
|
||||
|
||||
elif mtype == "stt_stream_start":
|
||||
# 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])
|
||||
|
||||
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", ""))
|
||||
sessions.end_session(req_id)
|
||||
|
||||
elif mtype == "config":
|
||||
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)
|
||||
if needs_load:
|
||||
logger.info("Config-Broadcast: Whisper-Modell -> %s%s",
|
||||
@@ -280,11 +608,10 @@ async def run_loop(runner: WhisperRunner) -> None:
|
||||
await _broadcast_status(ws, "error", error=str(e)[:200])
|
||||
asyncio.create_task(_swap_with_status(new_model))
|
||||
else:
|
||||
# Alle anderen Nachrichten debug-loggen — hilft beim Diagnostizieren,
|
||||
# ob stt_request ueberhaupt durch den RVS kommt
|
||||
logger.debug("Unbeachteter Type: %s", mtype)
|
||||
except Exception as e:
|
||||
logger.warning("Verbindung verloren: %s", e)
|
||||
sessions.detach_ws()
|
||||
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
|
||||
use_tls = False
|
||||
@@ -292,6 +619,8 @@ async def run_loop(runner: WhisperRunner) -> None:
|
||||
continue
|
||||
await asyncio.sleep(min(retry_s, 30))
|
||||
retry_s = min(retry_s * 2, 30)
|
||||
use_tls = RVS_TLS
|
||||
tls_fallback_tried = False
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -299,7 +628,11 @@ async def main() -> None:
|
||||
logger.error("RVS_HOST ist nicht gesetzt — Abbruch")
|
||||
sys.exit(1)
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user