Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d684bbb5f0 | ||
|
|
6d978d72b3 | ||
|
|
d221c1a320 | ||
|
|
d53dd83df4 | ||
|
|
58d60adbaf | ||
|
|
6404b99510 | ||
|
|
cfd17b200a | ||
|
|
ae3a112868 | ||
|
|
517a8ea942 | ||
|
|
4589f4abc3 | ||
|
|
e67659b78a | ||
|
|
988b074478 | ||
|
|
d5eea920a6 | ||
|
|
2c60a19ed6 | ||
|
|
752e2abc5f | ||
|
|
09d06011d5 | ||
|
|
4c0d8ce3fe | ||
|
|
734868efab | ||
|
|
7b46881677 | ||
|
|
cb89ebcd10 | ||
|
|
443b36bb9d | ||
|
|
12b8362d26 | ||
|
|
e628170760 | ||
|
|
7af0b76ad3 | ||
|
|
614ba48531 | ||
|
|
5b5591aa72 | ||
|
|
b70623bb78 | ||
|
|
73b6827f6b | ||
|
|
a7998bcd8b | ||
|
|
07df538763 | ||
|
|
5761c5f6b9 | ||
|
|
87755b9c58 | ||
|
|
b6195d3a53 | ||
|
|
b9354283af | ||
|
|
bf1db6cbf9 | ||
|
|
ea159d4ed7 | ||
|
|
dd1e1ac458 | ||
|
|
ddd77c7ad4 | ||
|
|
7c3c932054 | ||
|
|
d6c7502810 | ||
|
|
dc77933014 | ||
|
|
6a6b320f37 | ||
|
|
cc91497923 | ||
|
|
10e7faa92b | ||
|
|
d030cd7bc6 | ||
|
|
36641e6519 | ||
|
|
9b11c16479 | ||
|
|
8f9e3e5c79 |
@@ -97,6 +97,7 @@ ARIA hat zwei Rollen:
|
||||
| Compute-Node(s) (STT/TTS/LLM) | 1..n GPU-Rechner | `cd xtts && cp .env.example .env && docker compose up -d` |
|
||||
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
|
||||
| Host-Agent(en) 💻 (optional) | Direkt auf einem Rechner | `cd host-agent && ./build.sh` → Binary + `.env` auf den Rechner, starten (Details unten & in [`host-agent/README.md`](host-agent/README.md)) |
|
||||
| Android-Agent 📱📺 (optional) | Handy, Tablet, Fire TV | `cd host-agent/android && ./build.sh` → APK, oder per Satellit via ADB ausrollen (Details unten) |
|
||||
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
|
||||
|
||||
> Compute-Nodes sind optional: ohne sie faellt STT auf lokales Whisper (CPU,
|
||||
@@ -463,6 +464,44 @@ manueller Diagnostic-Klick) gehoeren seed_rules zum Brain-Code und werden
|
||||
mit jedem Deploy ausgerollt. Editieren = `SEED_RULES`-Liste anpassen,
|
||||
Brain neu starten.
|
||||
|
||||
### Beispiel: `flugwetter`
|
||||
|
||||
Zeigt gut, wie ein Skill mit Zugangsdaten und mehreren Quellen aussieht —
|
||||
Briefing fuer **EDWI** (Wilhelmshaven-Mariensiel, Piste 02/20), nur mit
|
||||
Python-Bordmitteln, keine pip-Pakete.
|
||||
|
||||
| Was | Woher | Kosten |
|
||||
|---|---|---|
|
||||
| METAR/TAF | aviationweather.gov | frei |
|
||||
| Wind, Boeen | Open-Meteo **ICON-D2** | frei |
|
||||
| Bedeckung, Sicht | DWD **MOSMIX** Station 10125 | frei |
|
||||
| GAFOR, Karten | **ftp.pcmet.de** | Stefans pc_met-Konto |
|
||||
|
||||
Die pc_met-Zugangsdaten stehen in der **Skill-Konfiguration**
|
||||
(`pcmet_user` / `pcmet_password`), nie im Chat. `ftp.pcmet.de` ist die vom DWD
|
||||
dokumentierte Kundenschnittstelle — kein Nachbauen des Browser-Logins noetig.
|
||||
`action=discover` listet, was auf dem Server liegt; `action=fetch` holt eine
|
||||
Karte nach `/shared/uploads` und gibt sie als `[FILE:]`-Marker zurueck, sodass
|
||||
ARIA sie anzeigen oder an einen Agenten schicken kann.
|
||||
|
||||
Zwei Dinge, die den Unterschied machen:
|
||||
|
||||
- **Seitenwind statt nur Wind.** Gerechnet gegen Piste 02/20 und gegen Stefans
|
||||
15-kt-Grenze (`crosswind_limit_kt`), bewertet werden die **Boeen**.
|
||||
- **Jede Zahl nennt ihre Herkunft.** Die Wolkenuntergrenze ist entweder
|
||||
*gemessen* (METAR), ein ausdruecklich markierter *Schaetzwert*
|
||||
(Temp/Taupunkt, 400-ft-Regel) oder *nicht verfuegbar* — dann steht da, dass
|
||||
man ins GAFOR schauen muss. `CAVOK` wird als eigene Aussage behandelt und
|
||||
unterdrueckt die Schaetzung, die sonst direkt widersprechen wuerde.
|
||||
Genau daran war ein Briefing vorher gescheitert: METAR meldete `NCD`
|
||||
(AUTO-Station), das Bodenmodell meldete bedeckt, und niemand wusste, was gilt.
|
||||
|
||||
Oben im Ergebnis steht eine **KURZFASSUNG in ganzen Saetzen zum Vorlesen**
|
||||
(„fuenftausend Fuss", nicht „5000 ft") — eine vorgelesene Tabelle versteht
|
||||
niemand. Fuer die Ausgabe auf einem Geraet kombiniert ARIA:
|
||||
`run_flugwetter` → optional `action=fetch` → `host_notify(text=<Kurzfassung>,
|
||||
image_path=<Karte>, mode='both', seconds=0)`.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic — Selbstcheck-UI und Einstellungen
|
||||
@@ -471,8 +510,8 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
|
||||
### Tabs
|
||||
|
||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test mit **Kontext-Streifen** (Hauptchat + Projekte als Karten mit Status-Dot; Tap wechselt den Fokus, rechts daneben **„+ Projekt"** — legt an, aktiviert und fokussiert in einem Schritt, der Knopf steht ausserhalb des scrollenden Streifens damit er bei vielen Projekten erreichbar bleibt), "ARIA denkt..."-Indikator, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). **Datum/Uhrzeit pro Gedanke** („angelegt 03.10.26, 09:14 · geändert … (vor 3 Std)") — `created_at`/`updated_at` lagen immer schon in der DB und wurden nur nie angezeigt. Dazu **Sortierung** (nach Typ gruppiert oder Anlage/Aenderung auf-/absteigend; bei Zeit-Sortierung flach statt gruppiert) und ein **🕒 Zeitraum-Filter** (von/bis, umschaltbar angelegt/geaendert, Schnellwahl Heute / 7 / 30 Tage) der auch fuer Such-Treffer gilt. Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
||||
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
|
||||
@@ -482,11 +521,18 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
||||
|
||||
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
|
||||
|
||||
**Projekt-Kontext**: ein Trigger merkt sich das Projekt, in dem er angelegt wurde (`project_id`), und die Antwort kommt beim Feuern **dort** zurueck. Ohne das landete jede Trigger-Antwort im Hauptchat — im Projekt-Thread blieb es still, und es sah aus wie „der Timer hat nicht gefeuert".
|
||||
|
||||
**Zustand ehrlich**: ein One-Shot-Timer wird nach dem Feuern automatisch deaktiviert. `trigger_list` zeigt darum ausdruecklich `SCHON GEFEUERT (Nx, zuletzt <Zeit>)` statt nur „DEAKTIVIERT" — sonst sind „hat sauber gefeuert" und „war nie an" nicht unterscheidbar, und ARIA berichtet guten Glaubens das Falsche.
|
||||
- **Integrationen → Jellyfin**: beliebig viele Server (`+`-Button) mit URL + API-Key. ARIA steuert die Wiedergabe ueber die **API** (`jellyfin_search` / `jellyfin_devices` / `jellyfin_play` / `jellyfin_control`) statt sich per `ui_tap` durch die Oberflaeche zu klicken — schneller und zuverlaessiger. Eine eingeschlafene Sitzung erkennt ARIA selbst und baut sie neu auf.
|
||||
- **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, **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
|
||||
|
||||
- **Agent-Historie** (Satelliten → Host-Agenten): pro Agent eine aufklappbare Historie der Befehle und Screenshots, gruppiert **pro User-Nachricht** — man sieht, welche Geraete-Aktionen zu welcher Frage gehoerten. Brain schickt dafuer je Host-Aktion ein `agent_history`-Event; die Bridge haelt es und antwortet auf `agent_history_query`.
|
||||
- **Auslastungs-Monitor** pro Compute-Box: Klick auf „Auslastung" zeigt **live `nvidia-smi`** (1s-Takt) plus zwei Verlaufs-Graphen (GPU-Last und Token-Verbrauch, 15s-Sampling, Ringpuffer ~2h) und ein Besen-Symbol zum Zuruecksetzen. Die **Historie liegt auf der Box**, nicht im Diagnostic — abgeholt per RVS (`node_stats_*`), kein SSH und kein Docker-Socket noetig.
|
||||
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
|
||||
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
|
||||
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
|
||||
@@ -497,7 +543,14 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **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`
|
||||
- **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 fuenf 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_call`**, `oauth_revoke`
|
||||
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
|
||||
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
|
||||
- **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude"
|
||||
- **Sicherheitsfilter-Rettung** (🛡 Sicherheitsfilter): Anthropics Filter („*... safeguards flagged this message*", `Details: [cyber]`) trifft gern genau die Themen, um die es hier geht — Ports, Tunnel, Zugangsdaten. Die Meldung kommt **nicht** als HTTP-Fehler: die CLI liefert ein normales Ergebnis mit `is_error:true` und dem Fehlertext im `content`, also genau dort, wo sonst ARIAs Antwort steht. Ungeprueft landete dieser Text als ARIAs Wort im Verlauf — und weil der Verlauf bei jedem Turn mitgeht, kippte danach **jede** weitere Frage im selben Projekt an derselben Stelle. Jetzt zwei Stufen: **(1)** derselbe Turn wird automatisch mit dem **Ausweich-Modell** neu gefragt (einstellbar unter Sprachmodell, Default: anderes Tier — der Filter haengt am Modell, Anthropic raet selbst zu „change your model"); der ausloesende Turn wird vermerkt, damit die naechsten Turns nicht erst wieder gegen die Wand laufen, und der Vermerk verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist. **(2)** Blockt auch das Ausweich-Modell, liegt es am Verlauf: der ausloesende Turn fliegt aus dem Prompt-Fenster (**Quarantaene**) — nicht geloescht, er bleibt in `conversation.jsonl`, steht mit Text im Panel und ist per „↩ Zurueckholen" wieder drin. Der rohe API-Fehlertext wird nie persistiert; ARIA sagt in eigenen Worten, was passiert ist. Das Panel zeigt pro Vorfall Projekt, Modell, Klassifikator-Code und ob die Blockade **noch wirkt** (= Turn liegt noch im Fenster). Zustand in `/data/safeguard.json`, Code in [`aria-brain/safeguard.py`](aria-brain/safeguard.py); gilt auch fuer das Memory-Destillat, das denselben Verlauf nochmal durchschickt. **Altlasten** — Fehlertexte, die vor diesem Fix schon in `conversation.jsonl` stehen und dort weiter vergiften — raeumt [`aria-brain/clean_poisoned_turns.py`](aria-brain/clean_poisoned_turns.py) einmalig weg (Dry-Run per Default, `--apply` schreibt mit Backup; findet jetzt beide Giftsorten und in Projekten genauso wie im Hauptchat)
|
||||
- **Datenschutz-Schalter**: die drei Kanaele, ueber die Claude Code zusaetzlich sendet (`DISABLE_ERROR_REPORTING`, `DISABLE_TELEMETRY`, `DISABLE_FEEDBACK_COMMAND`) pruefen und setzen. Geschrieben wird nach `~/.claude/settings.json` unter `env` — die CLI liest das bei jedem Aufruf, **kein Neustart noetig**. Geprueft wird, was wirklich im Prozess ankommt: eine per docker-compose gesetzte Variable gewinnt und wird als gesperrt angezeigt, statt einen wirkungslosen Haken anzubieten. **Nicht** dasselbe wie die Trainings-Einstellung — die steht im Anthropic-Konto (Link daneben)
|
||||
- **Warnung bei veralteten Proxy-Patches**: der Proxy kopiert `proxy-patches/` beim **Start**. Wer nach einem `git pull` nur neu startet — oder neu startet, bevor der Pull durch ist — laeuft still mit der alten Fassung. Nichts deutet darauf hin. Die Einstellungen vergleichen jetzt die Zeitstempel und zeigen den fertigen Befehl (`--force-recreate proxy`)
|
||||
- **Anhang-Limit** (System & Wartung): Groesse fuer Memory-Anhaenge, Default 64 MB. Liegt in `runtime.json`, das Brain liest es bei jedem Upload neu — greift sofort. Ueber 64 MB warnt die UI, weil die Handy-App darueber nichts mehr herunterlaedt
|
||||
|
||||
---
|
||||
|
||||
@@ -514,6 +567,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Wake-Word** (on-device, openWakeWord ONNX): "Hey Jarvis", "Alexa", "Hey Mycroft", "Hey Rhasspy" — Mikrofon hoert passiv mit, Konversation startet beim Schluesselwort. Komplett on-device via ONNX Runtime, kein API-Key, kein Cloud-Roundtrip, Audio verlaesst das Geraet nicht.
|
||||
- **VAD (Voice Activity Detection)**: Adaptive Schwelle (Baseline aus ersten 500ms Mic-Pegel + 6dB Offset). Konfigurierbare Stille-Toleranz (1.0–8.0s, Default 2.8s) bevor Auto-Stop greift. Max-Aufnahme einstellbar (1–30 min, Default 5 min)
|
||||
- **Nachricht anstellen statt abbrechen** (Queue pro Projekt): Schickst du eine zweite Nachricht waehrend ARIA noch am aktuellen Task arbeitet, wird sie **angestellt** statt den laufenden abzubrechen — laeuft der Reihe nach, pro Projekt unabhaengig. Wartende zeigen als `⏸`-Bubble (tippen entfernt sie aus der Warteschlange). Explizites Abbrechen laeuft ueber den Stop-Button am „ARIA denkt". Eine neue Sprachnachricht stoppt nur akustisch das TTS (sauberes Mikro), bricht die laufende Arbeit aber nicht mehr ab
|
||||
- **Zwischenruf** (📣): schiebt eine Korrektur in den **laufenden** Turn statt sie anzustellen — ohne Abbruch. Erscheint, sobald ARIA im Kontext arbeitet. Mit Text im Feld geht er sofort raus; **ohne Text schaltet er die naechste Aufnahme scharf**, dann geht der Zwischenruf per Sprache. Der laufende Turn wird dabei nicht gecancelt, nur die Wiedergabe gestoppt (sonst redet ARIA ins offene Mikro)
|
||||
- **Sprachnachricht zu Anhaengen**: Bild anhaengen und den Auftrag dazu **sprechen** statt tippen. Die Dateien gehen beim Antippen des Mikros sofort hoch (Upload laeuft parallel zum Reden); die Bridge fuehrt Anhang und gesprochenen Satz ueber die `clientMsgId` zu **einem** Turn zusammen und legt beides in **eine** Bubble. Ohne das sah ARIA ein Bild ohne Auftrag und danach einen Satz ohne Bild. Das Mikro ist darum immer sichtbar — auch mit Anhang oder getipptem Text
|
||||
- **Teilen-Ziel** (Android-Share): „Teilen → ARIA" aus WhatsApp, Galerie, Browser … → Dialog zeigt *was* geteilt wurde, dann *wohin* (Projekt oder Hauptchat), dann optional ein Text dazu (darf leer bleiben). Die native Seite kopiert die Dateien sofort in den App-Cache, weil die Leseerlaubnis einer `content://`-URI nur fuer diesen Intent gilt und nach der Projektauswahl schon erloschen waere
|
||||
- **Rueckfrage-Loop**: Stellt ARIA eine echte, blockierende Rueckfrage, **pausiert** die Queue und deine naechste Eingabe beantwortet sie — bis eine finale Antwort kommt, dann laeuft der naechste Queue-Eintrag (gleiches Muster). Banner „❓ ARIA fragt nach — deine Eingabe beantwortet das". ARIA signalisiert das ueber einen unsichtbaren `[[AWAIT]]`-Marker im Antworttext (das Modell deklariert den Zustand selbst, kein „endet-mit-?"-Raten; Brain strippt ihn und gibt `awaiting_reply` an App + Diagnostic durch)
|
||||
- **Pro-Projekt-Textfeld-Entwuerfe**: Der Feldinhalt bleibt beim Projektwechsel erhalten — in Projekt X tippen, zu Y wechseln (leeres Feld), zurueck zu X → dein Entwurf steht wieder da. Persistiert ueber Neustart. Gleiches Verhalten im Diagnostic
|
||||
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
|
||||
@@ -819,6 +875,24 @@ Jeder Dienst haengt an einem Compose-Profil. Ein Node startet ueber
|
||||
| `whisper` | aria-whisper-bridge | STT-Fallback (faster-whisper CUDA) |
|
||||
| `f5tts` | aria-f5tts-bridge | TTS (F5-TTS Voice Cloning) |
|
||||
| `llm` | aria-llama-swap + llm-adapter| Lokales LLM (llama-swap, OpenAI-kompat.) |
|
||||
| `openhands`| aria-openhands | OpenHands gegen das lokale LLM (optional) |
|
||||
|
||||
#### OpenAI-Endpunkt fuers lokale LLM
|
||||
|
||||
Das lokale LLM ist zusaetzlich als **OpenAI-kompatibler Endpunkt** erreichbar —
|
||||
damit laesst sich z.B. OpenHands oder jedes andere OpenAI-SDK darauf richten.
|
||||
Port und Key kommen aus der `.env` der Box (`LLM_OPENAI_PORT`,
|
||||
`LLM_OPENAI_API_KEY`, siehe `xtts/.env.example`).
|
||||
|
||||
> **Die `.env` gewinnt immer.** Steht der Wert dort, sind die Felder in der
|
||||
> Diagnostic **nur lesend** — sonst traegt man dort etwas ein, es wirkt nach dem
|
||||
> naechsten Container-Start nicht, und niemand versteht warum. Ist in der `.env`
|
||||
> nichts gesetzt, laesst sich der Key pro Box in der Diagnostic pflegen
|
||||
> (`llm_openai_config`).
|
||||
|
||||
Der Port ist ein Docker-Port-Mapping und darum **nie** aus der Diagnostic
|
||||
aenderbar. Zur Absicherung nach aussen: SSH-Tunnel statt den Port im LAN
|
||||
aufzureissen (Details in [`xtts/README.md`](xtts/README.md)).
|
||||
|
||||
### Architektur (Aufteilung auf mehrere Nodes)
|
||||
|
||||
@@ -1004,6 +1078,112 @@ keine offenen Ports; alle Kommandos werden geloggt. Gibt **vollen** Rechnerzugri
|
||||
|
||||
> Sichtbar in der Diagnostic unter **Satelliten → Host-Agenten 💻**.
|
||||
|
||||
### GUI-Steuerung (Desktop)
|
||||
|
||||
Auf Linux/Windows/macOS kann der Agent zusaetzlich die **grafische Oberflaeche**
|
||||
bedienen (`host_ui_tap` / `host_ui_text` / `host_ui_swipe` / `host_ui_key` /
|
||||
`host_app_launch`). Das ist **capability-gated**: der Agent meldet beim
|
||||
`host_hello` nur, was er wirklich kann — auf einem Linux-Server ohne X/Wayland
|
||||
fehlt die GUI-Faehigkeit einfach in der Liste, und ARIA sieht das. Eingabe-Weg
|
||||
je System: `windows` · `macos` · `xdotool` (X11) · `ydotool` (Wayland).
|
||||
|
||||
---
|
||||
|
||||
## Android-Agent 📱📺 — Handy, Tablet, Fire TV
|
||||
|
||||
Dieselbe APK laeuft auf **Handy/Tablet** und auf **Fire TV / Android TV**
|
||||
(`host-agent/android/`). Sie ist das Android-Gegenstueck zum Host-Agenten:
|
||||
ausgehende Verbindung zum RVS, kein offener Port, erscheint in der Diagnostic
|
||||
unter **Satelliten → Host-Agenten 💻**.
|
||||
|
||||
**Sehen** — Screenshot (MediaProjection) und Bildschirm-Inhalt als Struktur
|
||||
(`ui_dump` via Accessibility-Service, nur lesend). Beides geht auch bei
|
||||
gesperrtem Geraet.
|
||||
|
||||
**Steuern** — `ui_tap` / `ui_text` / `ui_swipe` / `ui_key` / `app_launch` ueber
|
||||
den Accessibility-Service. **Nur bei entsperrtem Geraet** — Android laesst
|
||||
Gesten auf dem Sperrbildschirm nicht zu; der Agent sagt das klar, statt stumm
|
||||
nichts zu tun. Auf dem Fire TV gibt es keinen Touchscreen, dafuer das D-Pad.
|
||||
|
||||
**Ausgeben** (v.a. Fire TV, dort ist ARIA ein echter Endpunkt):
|
||||
|
||||
| Weg | Was passiert |
|
||||
|-----|--------------|
|
||||
| Einblendung | Text ueber dem laufenden Bild (Overlay, braucht „Ueber anderen Apps anzeigen") |
|
||||
| Sprache | ARIAs **echte** Stimme — der Agent fordert das Audio per `tts_request` ueber den normalen F5-TTS-Pfad an und spielt das PCM ab |
|
||||
| Einstellung | Overlay oder Sprache als Standard, pro Anfrage ueberschreibbar (`mode`) |
|
||||
| Bild + Text | `host_notify(image_path=/shared/uploads/...)` blendet ein Bild MIT Text ein — z.B. eine Wetterkarte |
|
||||
|
||||
**Wie die Einblendung wieder weggeht.** Eine feste Anzeigedauer ist immer
|
||||
geraten: mal zu kurz zum Lesen, mal steht sie laengst im Weg. Darum bedeutet
|
||||
`seconds=0`: **bleibt, bis Stefan etwas tut.**
|
||||
|
||||
- *Fernbedienung*: der Accessibility-Dienst bekommt `onKeyEvent` und raeumt die
|
||||
Einblendung weg — gibt die Taste aber mit `return false` **weiter an den
|
||||
Film**. Es geht kein Tastendruck verloren, der Player bleibt bedienbar.
|
||||
- *Touch*: `FLAG_WATCH_OUTSIDE_TOUCH` meldet eine Beruehrung daneben; die geht
|
||||
dank `FLAG_NOT_TOUCHABLE` gleichzeitig an die App darunter.
|
||||
- *Notbremse*: nach 10 Minuten verschwindet sie in jedem Fall.
|
||||
|
||||
> Bewusst **nicht** der naheliegende Weg (Overlay faengt Tasten selbst ab):
|
||||
> dafuer muessten `FLAG_NOT_FOCUSABLE` und `FLAG_NOT_TOUCHABLE` weg — und dann
|
||||
> nimmt die Einblendung dem laufenden Film JEDE Eingabe, solange sie steht.
|
||||
>
|
||||
> Der Accessibility-Dienst braucht dafuer `canRequestFilterKeyEvents`. Nach
|
||||
> einem Update also pruefen, ob er in den Bedienungshilfen noch aktiv ist —
|
||||
> Android schaltet Dienste bei geaenderter Konfiguration gern ab.
|
||||
|
||||
> Fire TV Sticks haben oft **keine deutsche TTS-Engine**. Darum laeuft die
|
||||
> Sprachausgabe bewusst nicht ueber die Geraete-Stimme, sondern ueber ARIAs
|
||||
> eigene. Die Geraete-Stimme gibt es nur noch auf ausdrueckliche Anforderung
|
||||
> (`engine=device`).
|
||||
|
||||
**Audio-Fokus** — bevor ARIA redet, wird der laufende Film leiser (`duck`) oder
|
||||
angehalten (`pause`); danach laeuft er weiter. Sonst redet ARIA mitten in
|
||||
Netflix hinein und man versteht beides nicht.
|
||||
|
||||
**Sprachdialog** — der Agent kann auch *zuhoeren* (`listen` / `listen_stop`) und
|
||||
streamt das Mikrofon mit demselben Protokoll wie die App
|
||||
(`stt_stream_start` → `stt_audio_chunk` → `stt_stream_end`).
|
||||
|
||||
> **Turn-Ownership:** der RVS ist ein Broadcast-Relay ohne Adressierung — jeder
|
||||
> Client im Raum bekommt jede Antwort. Ohne Filter wuerden Handy und Fire TV
|
||||
> dieselbe Antwort gleichzeitig vorlesen. Jedes Geraet gibt darum nur aus, was
|
||||
> aus *seiner* eigenen Frage entstanden ist (`audioRequestId` → `messageId`).
|
||||
|
||||
### Bauen + ausrollen
|
||||
|
||||
```bash
|
||||
cd host-agent/android && ./build.sh # Docker (Android-SDK + Gradle)
|
||||
# -> dist/aria-android-agent.apk
|
||||
|
||||
# Release mit allen Agent-Artefakten (Linux/Windows/Android) als Gitea-Assets:
|
||||
cd host-agent && ./release_agent.sh 0.0.1.4
|
||||
```
|
||||
|
||||
Der Signaturschluessel liegt fest in `host-agent/android/aria-agent.keystore`
|
||||
(gitignored). Ohne ihn bekaeme jeder Build einen neuen Debug-Key, und Android
|
||||
verweigert die Installation mit *„Konflikt mit einem bestehenden Paket"* statt
|
||||
zu aktualisieren.
|
||||
|
||||
### Ausrollen per Satellit (ohne Kabel, ohne Abtippen)
|
||||
|
||||
Ein Satellit im selben Netz kann Android-Geraete per **ADB over Network**
|
||||
bespielen — besonders fuer den Fire TV, wo QR-Code-Scannen nicht geht:
|
||||
|
||||
```
|
||||
Diagnostic → Satelliten → Geraet → „Agent ausrollen"
|
||||
```
|
||||
|
||||
Der Satellit verbindet per ADB (Port 5555, wartet auf die Bestaetigung am
|
||||
Geraet — ein Klick genuegt), installiert die APK, erteilt die Berechtigungen per
|
||||
`appops`/`pm grant` und **provisioniert die RVS-Zugangsdaten per Intent** — man
|
||||
tippt am Fernseher nichts ein. Ein schon vergebener Geraetename wird dabei
|
||||
vorgeschlagen; nur wenn es keinen gibt, kommt der automatisch generierte.
|
||||
|
||||
> Die Berechtigungen bleiben ueber Neustarts erhalten; die Bildschirm-Aufnahme
|
||||
> schaltet sich nach einem Reboot selbst wieder scharf.
|
||||
|
||||
---
|
||||
|
||||
## Docker Volumes
|
||||
@@ -1032,6 +1212,40 @@ keine offenen Ports; alle Kommandos werden geloggt. Gibt **vollen** Rechnerzugri
|
||||
6. **Panic Button**: `docker compose down` → sofort stoppen
|
||||
7. **Alles loggen** — Stefan sieht immer was passiert
|
||||
|
||||
### Secrets und was bei Anthropic landet
|
||||
|
||||
ARIA laeuft ueber Claude Code auf einem Claude-Abo. **Alles, was ARIA sieht —
|
||||
auch jedes Tool-Ergebnis — wird uebertragen und gespeichert.** Bei Consumer-
|
||||
Plaenen (Free/Pro/Max) haengt es an einer Einstellung im Konto, ob daraus auch
|
||||
Trainingsdaten werden; die Aufbewahrung betraegt entsprechend 5 Jahre oder
|
||||
30 Tage ([claude.ai/settings/data-privacy-controls](https://claude.ai/settings/data-privacy-controls),
|
||||
Details in Anthropics [Data-usage-Doku](https://code.claude.com/docs/en/data-usage)).
|
||||
|
||||
Daraus folgen drei Regeln:
|
||||
|
||||
1. **Secrets nie in den Chat tippen.** Das ist der Weg, ueber den hier
|
||||
tatsaechlich einmal ein `client_secret` abgeflossen ist — nicht ueber ein
|
||||
Tool, sondern weil es eingetippt und von ARIA zurueckzitiert wurde. Der
|
||||
System-Prompt und die seed_rules weisen ARIA inzwischen an, das abzulehnen
|
||||
und ein Secret nie zu wiederholen.
|
||||
2. **Zugangsdaten gehoeren in eine Konfiguration, nicht in Code oder Chat.**
|
||||
OAuth → Diagnostic → OAuth-Apps. Alles andere (Benutzername/Passwort,
|
||||
API-Keys) → **Skill-Konfiguration** (`skill_set_config`), kommt zur Laufzeit
|
||||
als `CFG_<NAME>` an und wird in der Oberflaeche maskiert.
|
||||
3. **ARIA bekommt Zugangstokens nicht mehr zu sehen.** `oauth_get_token` sagt
|
||||
nur noch, OB authentifiziert ist. Fuer API-Aufrufe gibt es **`oauth_call`**:
|
||||
das Brain haengt die Authorization selbst an, fuehrt den Request aus und
|
||||
gibt nur die Antwort zurueck — der Token verlaesst den Brain-Prozess nicht.
|
||||
Abgesichert durch eine Host-Sperre: nur https und nur Hosts, die zum
|
||||
Provider gehoeren (Vergleich auf den letzten zwei Labels, **nicht** per
|
||||
`endswith` — sonst waere `boesespotify.com` durchgerutscht).
|
||||
|
||||
> Was ARIA NICHT schuetzt: sie laeuft mit Root im Proxy-Container und hat
|
||||
> `/shared` gemountet. Sie *kann* `/shared/config/*.json` lesen, wenn sie
|
||||
> hinschaut — und was sie liest, ist Tool-Ergebnis. Die Dateirechte (600)
|
||||
> helfen dagegen nicht. Behandle jedes dort erreichbare Secret als
|
||||
> „sichtbar, sobald ARIA hinschaut".
|
||||
|
||||
---
|
||||
|
||||
## Haeufige Befehle
|
||||
@@ -1073,14 +1287,28 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
`Hey Rhasspy` funktionieren sofort, eigene Wake-Words muessen aktuell noch als
|
||||
`.onnx`-Datei ins App-Bundle gelegt + zur Liste in `wakeword.ts` hinzugefuegt werden.
|
||||
Die Diagnostic-Upload-UI ist Phase 2.
|
||||
- **Audio-Format**: App nimmt AAC/MP4 auf, Bridge konvertiert via FFmpeg zu 16kHz PCM.
|
||||
- **Audio-Format**: Die App streamt live 16 kHz mono s16le an die STT-Bridge
|
||||
(`stt_stream_start` → `stt_audio_chunk` → `stt_stream_end`). Der alte Weg
|
||||
(AAC/MP4 aufnehmen, Bridge konvertiert per FFmpeg) existiert noch als Fallback.
|
||||
- **Sprachaufnahme ist nicht wiederaufsetzbar**: Die Audio-Chunks gehen live
|
||||
raus. Bricht die Verbindung mitten in der Aufnahme ab, fehlt dieses Stueck —
|
||||
es wird nicht gepuffert und nicht nachgeschickt. Gleiches gilt fuer die
|
||||
Sprachausgabe: F5-TTS streamt saetzeweise PCM, ein Abbruch verliert den Rest.
|
||||
Lokal aufnehmen + wiederaufsetzbar hochladen (Queue mit Teil-Uebertragung)
|
||||
waere der naechste Schritt, ist aber noch nicht gebaut.
|
||||
- **RVS Zombie-Connections**: WebSocket-Verbindungen sterben gelegentlich ohne Fehlermeldung.
|
||||
Bridge hat Ping-Check (5s), Diagnostic nutzt frische Verbindungen pro Request.
|
||||
- **Bildanalyse eingeschraenkt**: Bilder werden in `/shared/uploads/` gespeichert. ARIA kann
|
||||
sie per Bash/Read-Tool oeffnen, aber Claude Vision (direkte Bildanalyse) ist ueber den
|
||||
Proxy-Pfad (`claude --print`) noch nicht moeglich. ARIA sieht den Dateipfad, nicht das Bild.
|
||||
- **Dateigroesse**: Grosse Dateien (>5MB) koennen WebSocket-Limits ueberschreiten.
|
||||
Bilder werden in der App auf max 1920x1920px @ 80% Qualitaet komprimiert.
|
||||
App-seitig fragt die Aufnahme-Sperre den Socket statt den gemerkten Zustand.
|
||||
- **Dateigroesse**: Der RVS und alle Worker fahren `max_size` 1500 MB (≈1 GB
|
||||
binaer plus base64-Overhead); groessere Dateien weist die Bridge aktiv ab,
|
||||
statt die Verbindung reissen zu lassen. Die App holt ueber **25 MB** nichts
|
||||
mehr ungefragt nach und bremst beim ausdruecklichen Antippen bei **64 MB** —
|
||||
base64-in-JSON muss komplett in den Speicher, und ein 150-MB-Video hat das
|
||||
Handy per OOM umgebracht. Bilder werden in der App auf max 1920x1920px @ 80%
|
||||
Qualitaet komprimiert.
|
||||
- **Lautstarke Umgebung stoert die STT**: Laute Musik im Raum verschlechtert die
|
||||
Erkennung messbar. Bewusst akzeptiert — Wake-Wort-Bestaetigung und der
|
||||
Silero-Endpoint stehen als Opt-in bereit.
|
||||
|
||||
---
|
||||
|
||||
@@ -1190,6 +1418,52 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [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 C — ARIA greift auf echte Geraete durch (09–10/2026)
|
||||
|
||||
- [x] **Host-Agenten** (Linux/Windows/macOS): Standalone-Binary, ausgehend zum RVS, steuert den Rechner auf dem es laeuft. Windows-Build per Docker+Wine samt `setup.exe` (NSIS + nssm) das einen Dienst installiert und seine `.env` aus ProgramData liest. Plus **GUI-Steuerung** capability-gated (`windows`/`macos`/`xdotool`/`ydotool`) — ein Server ohne X meldet die Faehigkeit einfach nicht
|
||||
- [x] **Android-Agent** (eine APK fuer Handy + Fire TV): sehen (Screenshot via MediaProjection, `ui_dump` via Accessibility), steuern (tap/text/swipe/key/app_launch, nur entsperrt), ausgeben (Overlay ueber dem laufenden Bild + ARIAs **echte** Stimme per `tts_request`, weil Fire TV Sticks oft keine deutsche TTS-Engine haben), Audio-Fokus (Film wird leiser/angehalten), Sprachdialog mit demselben STT-Protokoll wie die App. Fester Signaturschluessel, damit Builds als Update statt als Paket-Konflikt ankommen
|
||||
- [x] **Ausrollen per Satellit** (ADB over Network): APK installieren, Berechtigungen per `appops`/`pm grant` erteilen und die **RVS-Zugangsdaten per Intent provisionieren** — am Fire TV tippt man nichts ein, QR-Scannen geht dort nicht. Geraetename am PC vergeben, vorhandener Name wird vorgeschlagen
|
||||
- [x] **Agent-Historie** in der Diagnostic: Befehle + Screenshots pro Agent, gruppiert pro User-Nachricht, aufklappbar
|
||||
- [x] **Jellyfin-Integration**: mehrere Server mit API-Key, ARIA steuert die Wiedergabe ueber die API statt sich durch die Oberflaeche zu klicken; eingeschlafene Sitzungen baut sie selbst neu auf
|
||||
- [x] **Auslastungs-Monitor** pro Compute-Box: live `nvidia-smi` + Verlaufs-Graphen (GPU + Tokens) ueber RVS, Historie liegt auf der Box
|
||||
- [x] **OpenAI-Endpunkt fuers lokale LLM** (+ optionales `openhands`-Profil); `.env` gewinnt immer, Diagnostic-Felder dann nur lesend
|
||||
- [x] **Teilen-Ziel in der App** + **Sprachnachricht zu Anhaengen** + **Zwischenruf per Sprache**
|
||||
- [x] **Trigger mit Projekt-Kontext**: die Antwort kommt dort zurueck, wo der Timer gestellt wurde (vorher immer Hauptchat), und `trigger_list` unterscheidet „schon gefeuert" von „deaktiviert"
|
||||
- [x] **Modell-Liste von Anthropic** statt handgepflegt — und feste Versionen greifen wirklich. Vorher schmolz `extractModel` jeden unbekannten Namen still auf `opus` ein: eine bewusst gewaehlte Version wurde ohne Meldung durch eine andere ersetzt. Dazu ein **Test-Knopf**, der zeigt, welches Modell tatsaechlich geantwortet hat — moeglich wurde das erst, nachdem `normalizeModelName` aufhoerte, den echten Namen zu ueberschreiben
|
||||
- [x] **Datenschutz**: die drei Claude-Code-Sendekanaele in der Diagnostic pruef- und abschaltbar (ohne Neustart), **`oauth_call`** statt Token-Herausgabe, Secrets-Regeln in Prompt und seed_rules, Warnung bei veralteten Proxy-Patches
|
||||
- [x] **Anhang-Limit einstellbar** (Default 20 → 64 MB, greift sofort) und **Zeitstempel/Sortierung/Zeitraum** bei den Gedanken
|
||||
- [x] **Flugwetter-Skill** mit pc_met-Anbindung, Seitenwind gegen Pistenrichtung und Herkunftsangabe pro Zahl; Ausgabe als Einblendung + Sprache auf einem Agenten
|
||||
- [x] **Einblendung geht per Tastendruck weg** statt nach geratener Zeit — ueber `onKeyEvent` des Accessibility-Dienstes, der die Taste an den Film weiterreicht
|
||||
|
||||
#### Jagd auf verlorene Sprachnachrichten (die lange Kette)
|
||||
|
||||
Ein hartnaeckiges „die erste Sprachnachricht kommt nicht durch" waren am Ende
|
||||
**fuenf unabhaengige Ursachen** — hier dokumentiert, weil jede einzelne fuer
|
||||
sich plausibel aussah:
|
||||
|
||||
- [x] **200-MB-Broadcast** — der RVS ist ein Broadcast-Relay: ein per
|
||||
`file_request` nachgeladenes 150-MB-Video ging als ~200 MB base64 an **alle**
|
||||
Clients. Die STT/TTS-Worker starben daran mit Code 1009 und nahmen die
|
||||
laufende Aufnahme mit; die App starb am OOM und wurde von Android neu
|
||||
gestartet → Auto-Re-Download → von vorn (Schleife alle ~70s). Fix dreifach:
|
||||
Worker-`max_size` an den RVS angeglichen, **Datei-Antworten gehen nur noch an
|
||||
den Fragenden** (requestId-Korrelation im RVS), und die App holt ueber 25 MB
|
||||
nichts mehr ungefragt
|
||||
- [x] **`spawn E2BIG`** im Proxy — der System-Prompt lief als Kommandozeilen-
|
||||
Argument gegen `ARG_MAX` (131072). Jetzt `--system-prompt-file`
|
||||
- [x] **RVS-Allow-List** verwarf fuenf Nachrichtentypen still (`stt_lease*`,
|
||||
`chat_ack`, `stt_transcribe_*`) — jede Aufnahme startete verspaetet, erste
|
||||
Silben weg
|
||||
- [x] **Whisper-Kaltstart** — Session wurde angelegt, bevor das Modell geladen
|
||||
war; Audio lief ins Leere
|
||||
- [x] **Zombie-Verbindung** in der App — zeigte „getrennt", empfing aber weiter;
|
||||
die Aufnahme-Sperre fragt jetzt den Socket statt den gemerkten Zustand
|
||||
- [x] **Lange Sprachnachrichten brachen mitten im Satz ab** — der Endpointer
|
||||
zaehlte seine **eigene** Rechenzeit als Stille: der 200ms-Tick wartete auf die
|
||||
Zwischen-Transkription, und die lief jedes Mal ueber den kompletten Buffer
|
||||
(bei zwei Minuten mehrere Sekunden). Jetzt laeuft sie nebenher und sieht nur
|
||||
die letzten 30s; der finale Text kommt unveraendert aus dem ganzen Buffer
|
||||
|
||||
### Phase 2 — ARIA wird produktiv
|
||||
|
||||
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, etc. — durch normale Anfragen)
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20500
|
||||
versionName "0.2.5.0"
|
||||
versionCode 20505
|
||||
versionName "0.2.5.5"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -49,6 +49,28 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
|
||||
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Mehrere Dateien auf einmal. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
||||
|
||||
/**
|
||||
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
|
||||
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
|
||||
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
|
||||
* erst beim naechsten Kaltstart auftauchen.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
|
||||
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
|
||||
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
|
||||
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
|
||||
try {
|
||||
val ctx = (application as? com.facebook.react.ReactApplication)
|
||||
?.reactNativeHost?.reactInstanceManager?.currentReactContext
|
||||
ShareIntentModule.notifyNewIntent(
|
||||
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
add(ShareIntentPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
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.bridge.WritableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
|
||||
* Browser ...) per "Teilen → ARIA" geschickt werden.
|
||||
*
|
||||
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
|
||||
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
|
||||
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
|
||||
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
|
||||
*
|
||||
* Ablauf:
|
||||
* App geschlossen → Intent liegt beim Start an → getSharedData()
|
||||
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
|
||||
*/
|
||||
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "ShareIntent"
|
||||
|
||||
companion object {
|
||||
private const val EVENT = "ShareReceived"
|
||||
|
||||
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
|
||||
@JvmStatic
|
||||
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
|
||||
if (ctx == null || intent == null || !isShare(intent)) return
|
||||
try {
|
||||
val data = extract(ctx, intent)
|
||||
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit(EVENT, data)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShare(i: Intent): Boolean =
|
||||
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
|
||||
|
||||
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
|
||||
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
|
||||
val out = Arguments.createMap()
|
||||
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
|
||||
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
@Suppress("DEPRECATION")
|
||||
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
?.forEach { uris.add(it) }
|
||||
}
|
||||
|
||||
val items: WritableArray = Arguments.createArray()
|
||||
for (uri in uris) {
|
||||
copyToCache(ctx, uri)?.let { items.pushMap(it) }
|
||||
}
|
||||
out.putArray("items", items)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
|
||||
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
|
||||
* Projektauswahl) waere sie womoeglich schon erloschen.
|
||||
*/
|
||||
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
|
||||
return try {
|
||||
val cr = ctx.contentResolver
|
||||
var name = "geteilt"
|
||||
var size = 0L
|
||||
cr.query(uri, null, null, null, null)?.use { c ->
|
||||
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val si = c.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (c.moveToFirst()) {
|
||||
if (ni >= 0) c.getString(ni)?.let { name = it }
|
||||
if (si >= 0) size = c.getLong(si)
|
||||
}
|
||||
}
|
||||
val type = cr.getType(uri) ?: "application/octet-stream"
|
||||
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
|
||||
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
|
||||
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
|
||||
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
|
||||
cr.openInputStream(uri)?.use { input ->
|
||||
dst.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return null
|
||||
|
||||
Arguments.createMap().apply {
|
||||
putString("path", dst.absolutePath)
|
||||
putString("name", name)
|
||||
putString("type", type)
|
||||
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
|
||||
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun getSharedData(promise: Promise) {
|
||||
try {
|
||||
val activity = currentActivity
|
||||
val intent = activity?.intent
|
||||
if (intent == null || !isShare(intent)) {
|
||||
promise.resolve(Arguments.createMap().apply {
|
||||
putString("text", "")
|
||||
putArray("items", Arguments.createArray())
|
||||
})
|
||||
return
|
||||
}
|
||||
val data = extract(reactContext, intent)
|
||||
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
|
||||
// nicht erneut oeffnet.
|
||||
activity.intent = Intent(Intent.ACTION_MAIN)
|
||||
promise.resolve(data)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("SHARE_READ_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
|
||||
@ReactMethod
|
||||
fun clearCache(promise: Promise) {
|
||||
try {
|
||||
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
|
||||
promise.resolve(true)
|
||||
} catch (e: Throwable) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
|
||||
@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 ShareIntentPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ShareIntentModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.0",
|
||||
"version": "0.2.5.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt.
|
||||
*
|
||||
* Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt
|
||||
* oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text,
|
||||
TextInput, TouchableOpacity, View,
|
||||
} from 'react-native';
|
||||
import brainApi, { Project } from '../services/brainApi';
|
||||
|
||||
export interface SharedItem {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
items: SharedItem[];
|
||||
/** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */
|
||||
initialText?: string;
|
||||
onCancel: () => void;
|
||||
/** projectId: '' = Hauptchat. text: optional, darf leer sein. */
|
||||
onSend: (projectId: string, text: string) => void;
|
||||
}
|
||||
|
||||
const fmtSize = (n: number) =>
|
||||
n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
: n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
|
||||
|
||||
const ShareModal: React.FC<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pid, setPid] = useState<string | null>(null); // null = noch nichts gewaehlt
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setPid(null);
|
||||
setText(initialText || '');
|
||||
setSending(false);
|
||||
setLoading(true);
|
||||
brainApi.listProjects(false)
|
||||
.then(ps => setProjects(ps || []))
|
||||
.catch(() => setProjects([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [visible, initialText]);
|
||||
|
||||
const send = useCallback(() => {
|
||||
if (pid === null || sending) return;
|
||||
setSending(true);
|
||||
onSend(pid, text.trim());
|
||||
}, [pid, text, sending, onSend]);
|
||||
|
||||
const preview = items.find(i => i.type?.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
|
||||
<View style={s.backdrop}>
|
||||
<View style={s.box}>
|
||||
<Text style={s.title}>Mit ARIA teilen</Text>
|
||||
|
||||
{/* Was wird geteilt */}
|
||||
<View style={s.itemsBox}>
|
||||
{preview ? (
|
||||
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
|
||||
) : null}
|
||||
<View style={{ flex: 1 }}>
|
||||
{items.length === 0 ? (
|
||||
<Text style={s.dim}>Nur Text</Text>
|
||||
) : items.slice(0, 3).map((it, i) => (
|
||||
<Text key={i} style={s.itemLine} numberOfLines={1}>
|
||||
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
|
||||
</Text>
|
||||
))}
|
||||
{items.length > 3 ? (
|
||||
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Wohin */}
|
||||
<Text style={s.label}>Wohin soll das?</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
|
||||
) : (
|
||||
<FlatList
|
||||
style={s.list}
|
||||
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
|
||||
keyExtractor={p => p.id || '__main__'}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => {
|
||||
const sel = pid === item.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[s.row, sel && s.rowSel]}
|
||||
onPress={() => setPid(item.id)}>
|
||||
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
|
||||
{item.id ? '📁 ' : '💬 '}{item.name}
|
||||
</Text>
|
||||
{sel ? <Text style={s.check}>✓</Text> : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Optionaler Text */}
|
||||
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
|
||||
placeholderTextColor="#666680"
|
||||
multiline
|
||||
/>
|
||||
|
||||
<View style={s.actions}>
|
||||
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
|
||||
<Text style={s.btnGhostText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
|
||||
onPress={send}
|
||||
disabled={pid === null || sending}>
|
||||
<Text style={s.btnText}>
|
||||
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const s = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
|
||||
box: {
|
||||
backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||
padding: 18, maxHeight: '88%',
|
||||
},
|
||||
title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 },
|
||||
itemsBox: {
|
||||
flexDirection: 'row', gap: 10, alignItems: 'center',
|
||||
backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14,
|
||||
},
|
||||
thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' },
|
||||
itemLine: { color: '#E0E0F0', fontSize: 13 },
|
||||
dim: { color: '#8888AA', fontSize: 12 },
|
||||
label: { color: '#8888AA', fontSize: 12, marginBottom: 6 },
|
||||
list: { maxHeight: 190, marginBottom: 14 },
|
||||
row: {
|
||||
flexDirection: 'row', alignItems: 'center', paddingVertical: 10,
|
||||
paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E',
|
||||
},
|
||||
rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' },
|
||||
rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 },
|
||||
rowTextSel: { color: '#3FFF9F', fontWeight: '600' },
|
||||
check: { color: '#3FFF9F', fontSize: 16 },
|
||||
input: {
|
||||
backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0',
|
||||
paddingHorizontal: 12, paddingVertical: 10, minHeight: 56,
|
||||
textAlignVertical: 'top', marginBottom: 16, fontSize: 14,
|
||||
},
|
||||
actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
|
||||
btnGhost: { paddingVertical: 11, paddingHorizontal: 18 },
|
||||
btnGhostText: { color: '#8888AA', fontSize: 15 },
|
||||
btn: {
|
||||
backgroundColor: '#FFD60A', borderRadius: 8,
|
||||
paddingVertical: 11, paddingHorizontal: 24,
|
||||
},
|
||||
btnDisabled: { backgroundColor: '#3A3A2A' },
|
||||
btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default ShareModal;
|
||||
@@ -37,7 +37,7 @@ import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||
import audioService from '../services/audio';
|
||||
import wakeWordService from '../services/wakeword';
|
||||
import ProjectsBrowser from '../components/ProjectsBrowser';
|
||||
import brainApi, { Project as BrainProject } from '../services/brainApi';
|
||||
import brainApi, { Project as BrainProject, ContextModels, prettyModel } from '../services/brainApi';
|
||||
import projectFocus from '../services/projectFocus';
|
||||
import phoneCallService from '../services/phoneCall';
|
||||
import { playWakeReadySound } from '../services/wakeReadySound';
|
||||
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
|
||||
import VoiceButton from '../components/VoiceButton';
|
||||
import FileUpload, { FileData } from '../components/FileUpload';
|
||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||
import ShareModal, { SharedItem } from '../components/ShareModal';
|
||||
import shareIntent from '../services/shareIntent';
|
||||
import MessageText from '../components/MessageText';
|
||||
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
||||
import Geolocation from '@react-native-community/geolocation';
|
||||
@@ -85,6 +87,11 @@ interface ChatMessage {
|
||||
/** Welcher Backend die Antwort erzeugt hat: 'local' | 'claude' | 'fast-path'.
|
||||
* Fuer den optionalen Quell-Badge (Einstellung aria_show_source, default aus). */
|
||||
answeredBy?: string;
|
||||
/** Welches Claude-Modell geantwortet hat (aufgeloeste Version) und warum:
|
||||
* 'einstellung' | 'pro-chat' | 'ausweich' (Sicherheitsfilter). Leer bei
|
||||
* Fast-Path/lokal und bei aelteren Bubbles. */
|
||||
model?: string;
|
||||
modelReason?: string;
|
||||
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
|
||||
messageId?: string;
|
||||
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
|
||||
@@ -198,6 +205,35 @@ function formatDur(sec: number): string {
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
|
||||
//
|
||||
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
|
||||
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
|
||||
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
|
||||
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
|
||||
//
|
||||
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
|
||||
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
|
||||
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
|
||||
//
|
||||
// Darum zwei Grenzen:
|
||||
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
|
||||
// Dokumente passen rein, Videos nicht.
|
||||
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
|
||||
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
|
||||
// Meldung statt eines Absturzes.
|
||||
/** Signatur von sendPendingAttachments — fuer den Ref weiter unten. */
|
||||
type SendAttachmentsFn = (
|
||||
messageText: string,
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||
) => Promise<void>;
|
||||
|
||||
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
|
||||
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
|
||||
|
||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||
|
||||
@@ -318,6 +354,11 @@ const ChatScreen: React.FC = () => {
|
||||
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
|
||||
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
|
||||
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
|
||||
// Modell pro Chat (Brain /context-model/list). Nur geladen, wenn die
|
||||
// Quell-Anzeige an ist — Mama braucht weder Chip noch Netzlast dafuer.
|
||||
const [ctxModels, setCtxModels] = useState<ContextModels | null>(null);
|
||||
const [modelPickerVisible, setModelPickerVisible] = useState(false);
|
||||
const refreshCtxModelsRef = useRef<() => void>(() => {});
|
||||
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
|
||||
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
|
||||
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
|
||||
@@ -348,6 +389,10 @@ const ChatScreen: React.FC = () => {
|
||||
} | null>(null);
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
||||
// Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden.
|
||||
const [shareItems, setShareItems] = useState<SharedItem[]>([]);
|
||||
const [shareText, setShareText] = useState('');
|
||||
const [shareVisible, setShareVisible] = useState(false);
|
||||
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
||||
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
||||
@@ -363,6 +408,16 @@ const ChatScreen: React.FC = () => {
|
||||
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||
// Zwischenruf per Sprache: der 📣-Knopf ohne Text "spannt" die naechste
|
||||
// Aufnahme als Zwischenruf. Mit Text schickt er sofort (wie bisher). Der Ref
|
||||
// wird gelesen, wenn die Aufnahme startet — der State nur fuer die Optik.
|
||||
const [interjectArmed, setInterjectArmed] = useState(false);
|
||||
const interjectArmedRef = useRef(false);
|
||||
// sendPendingAttachments ueber einen Ref: handleVoiceButtonStart steht weiter
|
||||
// oben in der Datei und wuerde die Funktion sonst in ihrer Temporal Dead Zone
|
||||
// greifen (TS2448 — siehe Kommentar an sendTextMessage). Umsortieren waere
|
||||
// riskanter als dieser Ref, weil an der Reihenfolge mehrere Callbacks haengen.
|
||||
const sendPendingAttachmentsRef = useRef<SendAttachmentsFn | null>(null);
|
||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||
const [ttsMuted, setTtsMuted] = useState(false);
|
||||
@@ -419,6 +474,33 @@ const ChatScreen: React.FC = () => {
|
||||
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||
const autoOpenPaths = useRef<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Re-Download anfordern — mit Groessen-Bremse.
|
||||
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
|
||||
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
|
||||
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
|
||||
*/
|
||||
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
|
||||
if (!att.serverPath) {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(
|
||||
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
|
||||
+ `Liegt auf dem Server unter ${att.serverPath}`,
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Eindeutige Message-ID generieren
|
||||
const nextId = (): string => {
|
||||
messageIdCounter.current += 1;
|
||||
@@ -688,6 +770,17 @@ const ChatScreen: React.FC = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Modell pro Chat laden — bei eingeschalteter Quell-Anzeige und bei jedem
|
||||
// Kontextwechsel. Kein Polling: ein Ausweich-Vermerk aendert sich nur durch
|
||||
// einen Turn, und nach jedem Turn wird ohnehin neu geladen (s. chat-Handler).
|
||||
useEffect(() => {
|
||||
refreshCtxModelsRef.current = () => {
|
||||
if (!showSource) return;
|
||||
brainApi.getContextModels().then(setCtxModels).catch(() => {});
|
||||
};
|
||||
refreshCtxModelsRef.current();
|
||||
}, [showSource, focusedProjectId]);
|
||||
|
||||
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
|
||||
useEffect(() => {
|
||||
wakeWordService.loadFromStorage().catch(() => {});
|
||||
@@ -854,13 +947,26 @@ const ChatScreen: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
setMessages(prev => {
|
||||
const missing: {id: string, serverPath: string}[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const msg of prev) {
|
||||
for (const att of msg.attachments || []) {
|
||||
if (att.serverPath && !att.uri) {
|
||||
missing.push({ id: msg.id, serverPath: att.serverPath });
|
||||
if (!att.serverPath || att.uri) continue;
|
||||
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
|
||||
// Sie bleiben als "tippen zum Laden" stehen — der User
|
||||
// entscheidet, ob er sie wirklich aufs Handy zieht.
|
||||
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
|
||||
skipped.push(`${att.name} (${fmtMB(att.size)})`);
|
||||
continue;
|
||||
}
|
||||
missing.push({ id: msg.id, serverPath: att.serverPath });
|
||||
}
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
|
||||
import('../services/logger')
|
||||
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
|
||||
for (const m of missing) {
|
||||
@@ -960,6 +1066,8 @@ const ChatScreen: React.FC = () => {
|
||||
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
|
||||
projectId: typeof m.project_id === 'string' ? m.project_id : '',
|
||||
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
|
||||
model: typeof m.model === 'string' ? m.model : '',
|
||||
modelReason: typeof m.modelReason === 'string' ? m.modelReason : '',
|
||||
...(cmid && { clientMsgId: cmid }),
|
||||
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
|
||||
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
|
||||
@@ -1256,7 +1364,11 @@ const ChatScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
setMessages(prev => {
|
||||
const newText = `\uD83C\uDFA4 ${sttText}`;
|
||||
// Zwischenruf per Sprache bekommt das Megafon statt des Mikros \u2014
|
||||
// sonst sieht die Bubble wie eine normale Frage aus, obwohl sie in
|
||||
// den laufenden Turn gegangen ist und keine eigene Antwort bekommt.
|
||||
const isInterject = !!(message.payload as any).interject;
|
||||
const newText = isInterject ? `\uD83D\uDCE3 Zwischenruf: ${sttText}` : `\uD83C\uDFA4 ${sttText}`;
|
||||
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
|
||||
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
|
||||
// gesendet wurden und ihre STT-Results ueberlappen.
|
||||
@@ -1271,7 +1383,9 @@ const ChatScreen: React.FC = () => {
|
||||
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
|
||||
// nimmt die ERSTE noch unaufgeloeste Placeholder.
|
||||
const idx = prev.findIndex(m =>
|
||||
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
||||
m.sender === 'user'
|
||||
&& (m.text.includes('Spracheingabe wird verarbeitet')
|
||||
|| m.text.includes('Zwischenruf wird verarbeitet'))
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const next = prev.slice();
|
||||
@@ -1303,6 +1417,10 @@ const ChatScreen: React.FC = () => {
|
||||
sender: 'user',
|
||||
text: diagText,
|
||||
timestamp: message.timestamp,
|
||||
// projectId MUSS mit: die Chat-Ansicht filtert danach. Ohne das
|
||||
// landete die Nachricht im Hauptchat-Topf und war in jedem Projekt
|
||||
// unsichtbar — man sah nur ARIAs Antwort, nicht die eigene Frage.
|
||||
projectId: (message.payload.projectId as string) || '',
|
||||
}]));
|
||||
}
|
||||
return;
|
||||
@@ -1344,7 +1462,12 @@ const ChatScreen: React.FC = () => {
|
||||
backupTs: (message.payload.backupTs as number) || undefined,
|
||||
projectId: ((message.payload as any).projectId as string) || '',
|
||||
answeredBy: ((message.payload as any).answeredBy as string) || '',
|
||||
model: ((message.payload as any).model as string) || '',
|
||||
modelReason: ((message.payload as any).modelReason as string) || '',
|
||||
};
|
||||
// Modell-Chip oben nachziehen — ein Turn kann aufs Ausweich-Modell
|
||||
// gewechselt sein, das soll man sofort sehen.
|
||||
if ((message.payload as any).model) refreshCtxModelsRef.current();
|
||||
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
|
||||
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
|
||||
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
|
||||
@@ -1802,9 +1925,20 @@ const ChatScreen: React.FC = () => {
|
||||
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||
if (ev.audioRequestId) {
|
||||
setMessages(prev => prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
// 'no-response' heisst: die Spracherkennung hat GAR NICHT geantwortet
|
||||
// (Worker neu gestartet / nicht erreichbar) — die Aufnahme ist also
|
||||
// wirklich verloren gegangen, Du hast nicht etwa nichts gesagt. Das
|
||||
// zeigen wir an, statt die Bubble stillschweigend zu entfernen; sonst
|
||||
// wirkt es, als waere die Nachricht nie gesendet worden.
|
||||
const lost = String(ev.reason || '').includes('no-response');
|
||||
setMessages(prev => lost
|
||||
? prev.map(m => (m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))
|
||||
? { ...m, text: '⚠️ Aufnahme ging verloren — die Spracherkennung war gerade nicht erreichbar. Bitte nochmal aufnehmen.' }
|
||||
: m)
|
||||
: prev.filter(m =>
|
||||
!(m.audioRequestId === ev.audioRequestId
|
||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||
}
|
||||
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
|
||||
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
|
||||
@@ -2330,11 +2464,18 @@ const ChatScreen: React.FC = () => {
|
||||
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
||||
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
||||
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
||||
const interruptAriaIfBusy = useCallback(() => {
|
||||
// forInterject: bei einem Zwischenruf darf der laufende Turn NICHT abgebrochen
|
||||
// werden — das ist der ganze Sinn. Die Wiedergabe stoppen wir trotzdem, sonst
|
||||
// redet ARIA in das offene Mikro hinein.
|
||||
const interruptAriaIfBusy = useCallback((forInterject = false) => {
|
||||
if (audioService.isPlayingAudio()) {
|
||||
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
return true;
|
||||
audioService.haltAllPlayback(forInterject
|
||||
? 'user ruft dazwischen — nur Wiedergabe stoppen'
|
||||
: 'user startet Aufnahme — Interrupt');
|
||||
if (!forInterject) {
|
||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||
}
|
||||
return !forInterject;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
@@ -2347,18 +2488,35 @@ const ChatScreen: React.FC = () => {
|
||||
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
rememberMyRequest(audioRequestId);
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const asInterject = interjectArmedRef.current;
|
||||
const wasInterrupted = interruptAriaIfBusy(asInterject);
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
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]));
|
||||
// Warten Anhaenge? Dann sind sie das Thema dieser Aufnahme: sofort hochladen
|
||||
// (laeuft parallel zum Reden) und der Bridge per clientMsgId sagen, dass der
|
||||
// Satz dazu noch kommt. Die Bubble mit den Bildern macht
|
||||
// sendPendingAttachments — sie traegt die audioRequestId und bekommt den
|
||||
// gesprochenen Text spaeter eingesetzt. Also hier KEINE zweite Bubble.
|
||||
let attachmentCmid = '';
|
||||
if (pendingAttachments.length > 0 && !asInterject && sendPendingAttachmentsRef.current) {
|
||||
attachmentCmid = nextClientMsgId();
|
||||
const forVoice = pendingAttachments;
|
||||
setPendingAttachments([]);
|
||||
await sendPendingAttachmentsRef.current('', forVoice, {
|
||||
audioRequestId, clientMsgId: attachmentCmid,
|
||||
});
|
||||
} else {
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: asInterject ? '📣 Zwischenruf wird verarbeitet...'
|
||||
: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||
audioRequestId,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
}
|
||||
|
||||
const { ok } = await audioService.startStreamingRecording({
|
||||
audioRequestId,
|
||||
@@ -2373,15 +2531,37 @@ const ChatScreen: React.FC = () => {
|
||||
endpointMs: await loadSttEndpointMs(),
|
||||
hardCapMs: await loadMaxRecordingMs(),
|
||||
projectId: focusedProjectIdRef.current,
|
||||
...(attachmentCmid && { clientMsgId: attachmentCmid }),
|
||||
...(asInterject && { interject: true }),
|
||||
});
|
||||
if (!ok) {
|
||||
if (attachmentCmid) {
|
||||
// Die Anhaenge sind SCHON hochgeladen — die Bubble darf nicht weg,
|
||||
// sonst verschwinden die Bilder aus der Ansicht, obwohl sie auf dem
|
||||
// Server liegen. Stattdessen ehrlich kennzeichnen; der Nutzer kann den
|
||||
// Auftrag einfach tippen.
|
||||
setMessages(prev => prev.map(m => m.audioRequestId === audioRequestId
|
||||
? { ...m, text: '📎 Anhang gesendet — Mikro war belegt, bitte tippen',
|
||||
audioRequestId: undefined }
|
||||
: m));
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Mikro nicht verfügbar — Anhang ist aber da', ToastAndroid.LONG);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
||||
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||
return false;
|
||||
}
|
||||
// Zwischenruf-Scharfschaltung gilt genau fuer EINE Aufnahme.
|
||||
if (asInterject) {
|
||||
interjectArmedRef.current = false;
|
||||
setInterjectArmed(false);
|
||||
}
|
||||
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||
return true;
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup,
|
||||
pendingAttachments]);
|
||||
|
||||
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||
@@ -2456,16 +2636,52 @@ const ChatScreen: React.FC = () => {
|
||||
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
|
||||
}, []);
|
||||
|
||||
// ── Teilen-Ziel ────────────────────────────────────────────────
|
||||
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
|
||||
// (App laeuft schon → onNewIntent im MainActivity).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const open = (p: { text: string; items: SharedItem[] }) => {
|
||||
if (!alive) return;
|
||||
if (!p.items.length && !p.text) return; // nichts dabei
|
||||
setShareItems(p.items);
|
||||
setShareText(p.text);
|
||||
setShareVisible(true);
|
||||
};
|
||||
shareIntent.getShared().then(open).catch(() => {});
|
||||
const unsub = shareIntent.onShare(open);
|
||||
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
|
||||
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
|
||||
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
|
||||
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
|
||||
const appSub = AppState.addEventListener('change', st => {
|
||||
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
|
||||
});
|
||||
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
|
||||
}, []);
|
||||
|
||||
|
||||
// Alle Pending Anhaenge + Text senden
|
||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
||||
if (pendingAttachments.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
||||
const sendPendingAttachments = useCallback(async (
|
||||
messageText: string,
|
||||
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
|
||||
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
|
||||
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||
// Sprach-Modus: der Nutzer spricht die Beschreibung gerade erst ein. Dann
|
||||
// gehen die Dateien SOFORT raus (der Upload laeuft parallel zum Reden),
|
||||
// aber ohne Text — den liefert spaeter der stt_endpoint, und die Bridge
|
||||
// merged beides ueber die clientMsgId zu einem Turn.
|
||||
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||
) => {
|
||||
const atts = explicitAttachments ?? pendingAttachments;
|
||||
if (atts.length === 0) return;
|
||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
|
||||
const location = await getCurrentLocation();
|
||||
const msgId = nextId();
|
||||
|
||||
// Alle Attachments fuer die Chat-Nachricht sammeln
|
||||
const attachments: Attachment[] = [];
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
for (const { file, isPhoto } of atts) {
|
||||
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
|
||||
const name = isPhoto ? file.fileName : file.name;
|
||||
const base64 = file.base64 || '';
|
||||
@@ -2483,16 +2699,23 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
|
||||
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
|
||||
const cmid = messageText ? nextClientMsgId() : undefined;
|
||||
// Im Sprach-Modus kommt sie von aussen: dieselbe ID muss am
|
||||
// stt_stream_start haengen, damit die Bridge Anhang und Satz zusammenfuehrt.
|
||||
const cmid = voiceMode ? voiceMode.clientMsgId : (messageText ? nextClientMsgId() : undefined);
|
||||
const activePid = focusedProjectIdRef.current;
|
||||
const userMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||
// Im Sprach-Modus bekommt DIESE Bubble gleich den gesprochenen Text —
|
||||
// via audioRequestId-Match, genau wie eine normale Sprachnachricht. So
|
||||
// stehen Bild und Satz zusammen in einer Bubble statt in zwei.
|
||||
text: voiceMode ? '🎙 Spracheingabe wird verarbeitet...'
|
||||
: (messageText || `${atts.length} Anhang/Anhaenge`),
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
projectId: activePid,
|
||||
...(cmid && {
|
||||
...(voiceMode && { audioRequestId: voiceMode.audioRequestId }),
|
||||
...(cmid && !voiceMode && {
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
@@ -2501,7 +2724,7 @@ const ChatScreen: React.FC = () => {
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
// Alle Dateien an RVS senden + auf Disk speichern
|
||||
for (const { file, isPhoto } of pendingAttachments) {
|
||||
for (const { file, isPhoto } of atts) {
|
||||
const name = isPhoto ? file.fileName : file.name;
|
||||
const base64 = file.base64 || '';
|
||||
const mimeType = file.type || '';
|
||||
@@ -2530,6 +2753,9 @@ const ChatScreen: React.FC = () => {
|
||||
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
||||
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
||||
...(cmid && { clientMsgId: cmid }),
|
||||
// awaitVoice: die Bridge soll den Anhang NICHT nach 0.8s alleine ans
|
||||
// Brain schicken, sondern auf die Sprachnachricht warten.
|
||||
...(voiceMode && { awaitVoice: true }),
|
||||
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||
...(location && { location }),
|
||||
});
|
||||
@@ -2537,8 +2763,8 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
||||
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
||||
// adressieren.
|
||||
if (messageText && cmid) {
|
||||
// adressieren. Im Sprach-Modus gibt es hier noch keinen Text.
|
||||
if (messageText && cmid && !voiceMode) {
|
||||
dispatchWithAck(cmid, 'chat', {
|
||||
text: messageText,
|
||||
voice: localXttsVoiceRef.current,
|
||||
@@ -2551,11 +2777,41 @@ const ChatScreen: React.FC = () => {
|
||||
setPendingAttachments([]);
|
||||
setInputText('');
|
||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||
// Ref aktuell halten — handleVoiceButtonStart greift die Funktion von hier
|
||||
// (steht weiter oben in der Datei, siehe sendPendingAttachmentsRef).
|
||||
sendPendingAttachmentsRef.current = sendPendingAttachments;
|
||||
|
||||
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
||||
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
||||
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
|
||||
const handleShareSend = useCallback(async (projectId: string, text: string) => {
|
||||
try {
|
||||
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
|
||||
// steht nach dem Senden gleich im richtigen Kontext.
|
||||
setFocusedProjectId(projectId);
|
||||
focusedProjectIdRef.current = projectId;
|
||||
|
||||
const files = await shareIntent.toFileData(shareItems);
|
||||
if (files.length) {
|
||||
// Liste explizit uebergeben: der State waere hier noch leer.
|
||||
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
|
||||
} else if (text) {
|
||||
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
|
||||
setInputText(text);
|
||||
}
|
||||
shareIntent.clearShareCache();
|
||||
} catch (e) {
|
||||
console.warn('[Share] Senden fehlgeschlagen:', e);
|
||||
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
|
||||
} finally {
|
||||
setShareVisible(false);
|
||||
setShareItems([]);
|
||||
setShareText('');
|
||||
}
|
||||
}, [shareItems, sendPendingAttachments]);
|
||||
|
||||
const sendTextMessage = useCallback(async () => {
|
||||
const text = inputText.trim();
|
||||
|
||||
@@ -2790,16 +3046,15 @@ const ChatScreen: React.FC = () => {
|
||||
) : att.type === 'image' && !att.uri ? (
|
||||
<TouchableOpacity
|
||||
style={styles.attachmentFile}
|
||||
onPress={() => {
|
||||
if (att.serverPath) {
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||
}
|
||||
}}
|
||||
onPress={() => { requestAttachment(att, item.id); }}
|
||||
>
|
||||
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||
<Text style={styles.attachmentFileSize}>
|
||||
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
|
||||
{!att.serverPath ? '(nicht verfuegbar)'
|
||||
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
|
||||
? `(${fmtMB(att.size)} — tippen zum Laden)`
|
||||
: '(tippen zum Laden)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : att.type === 'audio' ? (
|
||||
@@ -2836,11 +3091,9 @@ const ChatScreen: React.FC = () => {
|
||||
}
|
||||
// Re-Download via file_request \u2192 bei file_response wird die
|
||||
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||
if (att.serverPath) {
|
||||
autoOpenPaths.current.add(att.serverPath);
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||
} else if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
|
||||
if (!requestAttachment(att, item.id) && att.serverPath) {
|
||||
autoOpenPaths.current.delete(att.serverPath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -2912,7 +3165,15 @@ const ChatScreen: React.FC = () => {
|
||||
claude: { t: 'Claude', c: '#0096FF' },
|
||||
'fast-path': { t: '⚡ fast', c: '#AF7BFF' },
|
||||
};
|
||||
const b = map[ab] || { t: ab, c: '#8A8AA0' };
|
||||
let b = map[ab] || { t: ab, c: '#8A8AA0' };
|
||||
// Bei Claude das echte Modell — aeltere Bubbles ohne Modell
|
||||
// bleiben bei "Claude".
|
||||
if (ab === 'claude' && item.model) {
|
||||
const n = prettyModel(item.model, ctxModels?.names);
|
||||
b = item.modelReason === 'ausweich'
|
||||
? { t: '↪ ' + n, c: '#FF9F0A' }
|
||||
: { t: n, c: '#0096FF' };
|
||||
}
|
||||
return <Text style={{ fontSize: 9, fontWeight: 'bold', color: b.c, marginLeft: 6 }}>{b.t}</Text>;
|
||||
})() : null}
|
||||
{item.text.length > 0 ? (
|
||||
@@ -3158,6 +3419,27 @@ const ChatScreen: React.FC = () => {
|
||||
</Text>
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||||
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
|
||||
{/* Modell dieses Chats — nur mit Quell-Anzeige (Einstellungen),
|
||||
sonst bleibt die Kopfzeile so schlicht wie bisher. Orange mit
|
||||
↪, wenn der Chat gerade aufs Ausweich-Modell ausweicht. */}
|
||||
{showSource && (() => {
|
||||
const row = ctxModels?.rows.find(r => r.context_id === (focusedProjectId || ''));
|
||||
if (!row) return null;
|
||||
const fb = row.reason === 'ausweich';
|
||||
const color = fb ? '#FF9F0A' : row.reason === 'pro-chat' ? '#0096FF' : '#8888AA';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => setModelPickerVisible(true)}
|
||||
hitSlop={{top:8,bottom:8,left:8,right:8}}
|
||||
style={{ paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,
|
||||
borderWidth: 1, borderColor: color + '88', backgroundColor: color + '22' }}
|
||||
>
|
||||
<Text style={{ color, fontSize: 10, fontWeight: '700' }} numberOfLines={1}>
|
||||
{(fb ? '↪ ' : '🧠 ') + prettyModel(row.effective, ctxModels?.names)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})()}
|
||||
{/* Direkter Zurueck-zum-Hauptchat-Button — nur wenn man in einem
|
||||
Projekt ist. Ein Tap statt Drawer→Hauptchat. */}
|
||||
{!isMain && (
|
||||
@@ -3178,6 +3460,83 @@ const ChatScreen: React.FC = () => {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Modell-Auswahl fuer den fokussierten Chat */}
|
||||
<Modal visible={modelPickerVisible} transparent animationType="fade"
|
||||
onRequestClose={() => setModelPickerVisible(false)}>
|
||||
<Pressable style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 }}
|
||||
onPress={() => setModelPickerVisible(false)}>
|
||||
<Pressable style={{ backgroundColor: '#15151E', borderRadius: 12, padding: 16, maxHeight: '80%' }}
|
||||
onPress={() => {}}>
|
||||
{(() => {
|
||||
const cid = focusedProjectId || '';
|
||||
const row = ctxModels?.rows.find(r => r.context_id === cid);
|
||||
const names = ctxModels?.names;
|
||||
const pick = (model: string) => {
|
||||
brainApi.setContextModel(cid, model)
|
||||
.then(d => { setCtxModels(d); setModelPickerVisible(false); })
|
||||
.catch(e => Alert.alert('Modell', 'Setzen fehlgeschlagen: ' + (e?.message || e)));
|
||||
};
|
||||
const Item = ({ id, label, active }: { id: string; label: string; active: boolean }) => (
|
||||
<TouchableOpacity onPress={() => pick(id)}
|
||||
style={{ paddingVertical: 10, paddingHorizontal: 10, borderRadius: 8,
|
||||
backgroundColor: active ? 'rgba(0,150,255,0.18)' : 'transparent' }}>
|
||||
<Text style={{ color: active ? '#0096FF' : '#E0E0F0', fontSize: 14, fontWeight: active ? '700' : '400' }}>
|
||||
{(active ? '● ' : '○ ') + label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
const sections: [string, string][] = [
|
||||
['tier', 'Automatisch (neueste Version)'],
|
||||
['main', 'Aktuelle Versionen'],
|
||||
['overflow', 'Ältere Versionen'],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Text style={{ color: '#E0E0F0', fontSize: 16, fontWeight: '700' }}>
|
||||
Modell für {cid ? (projectNameById[cid] || cid) : 'den Hauptchat'}
|
||||
</Text>
|
||||
<Text style={{ color: '#8888AA', fontSize: 12, marginTop: 4, marginBottom: 10 }}>
|
||||
{row ? 'Läuft gerade auf ' + prettyModel(row.effective, names)
|
||||
+ (row.reason === 'ausweich' ? ' (Ausweich — Sicherheitsfilter)'
|
||||
: row.reason === 'pro-chat' ? ' (für diesen Chat gewählt)' : ' (aus der Einstellung)')
|
||||
: 'Lade …'}
|
||||
</Text>
|
||||
{row?.reason === 'ausweich' && (
|
||||
<TouchableOpacity
|
||||
onPress={() => brainApi.contextModelBack(cid)
|
||||
.then(d => { setCtxModels(d); setModelPickerVisible(false); })
|
||||
.catch(e => Alert.alert('Modell', 'Zurück fehlgeschlagen: ' + (e?.message || e)))}
|
||||
style={{ backgroundColor: 'rgba(255,159,10,0.18)', borderColor: '#FF9F0A', borderWidth: 1,
|
||||
borderRadius: 8, padding: 10, marginBottom: 10 }}>
|
||||
<Text style={{ color: '#FF9F0A', fontSize: 13, fontWeight: '700' }}>
|
||||
↩ Zurück zu {prettyModel(row.selected, names)}
|
||||
</Text>
|
||||
<Text style={{ color: '#B0905A', fontSize: 11, marginTop: 2 }}>
|
||||
Blockt der Filter erneut, weicht ARIA eben wieder aus.
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<ScrollView>
|
||||
<Item id="" label={'Einstellung (' + prettyModel(ctxModels?.default || '', names) + ')'}
|
||||
active={!row?.override} />
|
||||
{sections.map(([sec, label]) => {
|
||||
const list = (ctxModels?.choices || []).filter(c => (c.section || 'tier') === sec);
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<View key={sec} style={{ marginTop: 8 }}>
|
||||
<Text style={{ color: '#555570', fontSize: 11, marginBottom: 2 }}>{label}</Text>
|
||||
{list.map(c => <Item key={c.id} id={c.id} label={c.name} active={row?.override === c.id} />)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{/* Projekt-Drawer als Modal */}
|
||||
<ProjectsBrowser
|
||||
visible={projectsVisible}
|
||||
@@ -3391,7 +3750,9 @@ const ChatScreen: React.FC = () => {
|
||||
style={styles.textInput}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
|
||||
placeholder={pendingAttachments.length > 0
|
||||
? "Text zu den Anhaengen — oder einsprechen 🎤"
|
||||
: "Nachricht an ARIA..."}
|
||||
placeholderTextColor="#555570"
|
||||
multiline
|
||||
maxLength={4000}
|
||||
@@ -3399,29 +3760,52 @@ const ChatScreen: React.FC = () => {
|
||||
returnKeyType="send"
|
||||
/>
|
||||
|
||||
{/* Senden oder Sprache */}
|
||||
{/* Rechte Knopfreihe.
|
||||
Das Mikro ist jetzt IMMER da \u2014 auch mit Anhang oder getipptem Text.
|
||||
Vorher war es ein Entweder-oder: sobald etwas im Feld stand oder ein
|
||||
Bild dranhing, verschwand das Mikro. Damit ging weder "Bild anhaengen
|
||||
und den Auftrag dazu sprechen" noch ein Zwischenruf per Sprache.
|
||||
Der Senden-Pfeil und die Mund-/Ohr-Knoepfe weichen dafuer je nach
|
||||
Lage, damit die Zeile nicht ueberlaeuft. */}
|
||||
|
||||
{/* Zwischenruf \u2014 sichtbar, wenn ARIA im aktiven Kontext arbeitet (oder
|
||||
noch scharf geschaltet ist). Mit Text: sofort abschicken, wie bisher.
|
||||
Ohne Text: die naechste Aufnahme geht als Zwischenruf in den
|
||||
laufenden Turn, statt sich als neue Anfrage anzustellen. */}
|
||||
{interjectArmed || (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.interjectButton, interjectArmed && styles.interjectButtonArmed]}
|
||||
onPress={() => {
|
||||
if (inputText.trim()) { sendInterject(); return; }
|
||||
const next = !interjectArmedRef.current;
|
||||
interjectArmedRef.current = next;
|
||||
setInterjectArmed(next);
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(
|
||||
next ? 'N\u00E4chste Aufnahme geht als Zwischenruf raus'
|
||||
: 'Zwischenruf abgew\u00E4hlt',
|
||||
ToastAndroid.SHORT);
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={inputText.trim() ? 'Zwischenruf senden' : 'Zwischenruf per Sprache'}
|
||||
>
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
|
||||
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||
<>
|
||||
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
|
||||
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
|
||||
sie anzustellen. */}
|
||||
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
|
||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<VoiceButton
|
||||
onTapStart={handleVoiceButtonStart}
|
||||
onTapStop={handleVoiceButtonStop}
|
||||
disabled={connectionState !== 'connected'}
|
||||
wakeWordActive={wakeWordActive}
|
||||
/>
|
||||
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
|
||||
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
|
||||
{ttsDeviceEnabled && (
|
||||
@@ -3693,6 +4077,13 @@ const ChatScreen: React.FC = () => {
|
||||
{/* Datei-Upload Modal */}
|
||||
<Modal visible={showFileUpload} transparent animationType="slide">
|
||||
<View style={styles.modalOverlay}>
|
||||
<ShareModal
|
||||
visible={shareVisible}
|
||||
items={shareItems}
|
||||
initialText={shareText}
|
||||
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
|
||||
onSend={handleShareSend}
|
||||
/>
|
||||
<FileUpload
|
||||
onFileSelected={handleFileSelected}
|
||||
onCancel={() => setShowFileUpload(false)}
|
||||
@@ -3940,6 +4331,13 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
// Scharf geschaltet: die naechste Aufnahme geht als Zwischenruf raus. Heller
|
||||
// plus Rand, damit man das vor dem Sprechen sieht und nicht erst danach.
|
||||
interjectButtonArmed: {
|
||||
backgroundColor: '#FFB340',
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFF',
|
||||
},
|
||||
interjectIcon: {
|
||||
fontSize: 18,
|
||||
},
|
||||
|
||||
@@ -1141,7 +1141,14 @@ class AudioService {
|
||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||
projectId?: string;
|
||||
}): Promise<{ requestId: string; ok: boolean }> {
|
||||
/** clientMsgId wartender Anhaenge: diese Aufnahme ist ihre Beschreibung.
|
||||
* Die Bridge fuehrt Anhang + gesprochenen Satz darueber zu EINEM Turn
|
||||
* zusammen, statt das Bild ohne Auftrag und den Satz ohne Bild zu sehen. */
|
||||
clientMsgId?: string;
|
||||
/** Zwischenruf: der Text geht in den LAUFENDEN Turn, nicht als neue
|
||||
* Anfrage in die Warteschlange (Sprach-Gegenstueck zum 📣-Knopf). */
|
||||
interject?: boolean;
|
||||
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||
if (this.recordingState !== 'idle') {
|
||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||
return { requestId: '', ok: false };
|
||||
@@ -1155,12 +1162,32 @@ class AudioService {
|
||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||
return { requestId: '', ok: false };
|
||||
}
|
||||
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
|
||||
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
|
||||
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
|
||||
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
|
||||
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
|
||||
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
|
||||
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
|
||||
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
|
||||
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
|
||||
// Nutzer kann gar nicht mehr sprechen.
|
||||
if (!rvs.isSocketOpen()) {
|
||||
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
|
||||
rvs.getState());
|
||||
return { requestId: '', ok: false, error: 'not-connected' };
|
||||
}
|
||||
|
||||
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||
this.stopPlayback();
|
||||
|
||||
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
this.streamRequestId = requestId;
|
||||
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
|
||||
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
|
||||
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
|
||||
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
|
||||
this.setState('recording');
|
||||
this.streamAudioRequestId = opts.audioRequestId || '';
|
||||
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
||||
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
||||
@@ -1202,20 +1229,17 @@ class AudioService {
|
||||
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
||||
}
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
if (!started) {
|
||||
throw new Error('PcmStreamRecorder.start returned false');
|
||||
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
|
||||
// Sitzung zu starten, die niemand mehr erwartet.
|
||||
if (this.streamRequestId !== requestId) {
|
||||
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
|
||||
return { requestId: '', ok: false, error: 'superseded' };
|
||||
}
|
||||
|
||||
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
|
||||
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
|
||||
await this._captureMediaActive();
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestExclusive().catch(() => {});
|
||||
|
||||
this.setState('recording');
|
||||
|
||||
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
|
||||
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
|
||||
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
|
||||
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
|
||||
// gar nichts uebrig.
|
||||
rvs.send('stt_stream_start' as any, {
|
||||
requestId,
|
||||
audioRequestId: opts.audioRequestId || '',
|
||||
@@ -1228,20 +1252,44 @@ class AudioService {
|
||||
sampleRate: 16000,
|
||||
projectId: opts.projectId || '',
|
||||
targetInstance: this.streamTargetInstance,
|
||||
...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}),
|
||||
...(opts.interject ? { interject: true } : {}),
|
||||
});
|
||||
|
||||
const started = await PcmStreamRecorder.start();
|
||||
if (!started) {
|
||||
throw new Error('PcmStreamRecorder.start returned false');
|
||||
}
|
||||
|
||||
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
|
||||
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
|
||||
await this._captureMediaActive();
|
||||
this._cancelDeferredFocusRelease();
|
||||
AudioFocus?.requestExclusive().catch(() => {});
|
||||
|
||||
|
||||
// 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.
|
||||
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
|
||||
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
|
||||
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
|
||||
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
|
||||
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
|
||||
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
|
||||
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
|
||||
// der zweite Versuch klappte, weil man da sofort losredet.
|
||||
const NO_SPEECH_GRACE_MS = 2500;
|
||||
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
|
||||
if (noSpeechMs > 0) {
|
||||
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
|
||||
this.streamNoSpeechTimer = setTimeout(() => {
|
||||
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
||||
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
|
||||
requestId.slice(0, 12), noSpeechMs);
|
||||
requestId.slice(0, 12), effectiveMs);
|
||||
this.cancelStreamingRecording('no-speech').catch(() => {});
|
||||
}
|
||||
}, noSpeechMs);
|
||||
}, effectiveMs);
|
||||
}
|
||||
|
||||
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
||||
|
||||
@@ -260,6 +260,43 @@ export interface Trigger {
|
||||
|
||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Modell pro Chat ──────────────────────────────────────────────────
|
||||
// reason: 'einstellung' (brainModel) | 'pro-chat' (fuer diesen Chat gewaehlt)
|
||||
// | 'ausweich' (Sicherheitsfilter hat das gewaehlte Modell blockiert)
|
||||
export interface ContextModelRow {
|
||||
context_id: string;
|
||||
name: string;
|
||||
override: string; // '' = folgt der Einstellung
|
||||
selected: string; // gewaehlt (Einstellung oder pro Chat)
|
||||
effective: string; // was der naechste Turn tatsaechlich nutzt
|
||||
reason: 'einstellung' | 'pro-chat' | 'ausweich' | string;
|
||||
blocked: string[];
|
||||
}
|
||||
|
||||
export interface ContextModelChoice {
|
||||
id: string;
|
||||
name: string;
|
||||
section: 'tier' | 'main' | 'overflow' | string;
|
||||
}
|
||||
|
||||
export interface ContextModels {
|
||||
default: string;
|
||||
rows: ContextModelRow[];
|
||||
choices: ContextModelChoice[];
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
/** "claude-sonnet-5-5" → "Sonnet 5.5". Katalog-Namen (names) haben Vorrang;
|
||||
* Datums-Suffixe ("-20251001") gehoeren nicht zur Version. Gleiche Logik wie
|
||||
* prettyModel() in der Diagnostic. */
|
||||
export function prettyModel(id: string, names?: Record<string, string>): string {
|
||||
if (!id) return '';
|
||||
if (names && names[id]) return names[id];
|
||||
const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-|$)/.exec(id);
|
||||
if (m) return m[1][0].toUpperCase() + m[1].slice(1) + ' ' + m[2] + (m[3] ? '.' + m[3] : '');
|
||||
return id.charAt(0).toUpperCase() + id.slice(1);
|
||||
}
|
||||
|
||||
export const brainApi = {
|
||||
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
|
||||
getMemory(id: string): Promise<Memory> {
|
||||
@@ -682,6 +719,25 @@ export const brainApi = {
|
||||
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
|
||||
return _send('/projects/queue-status');
|
||||
},
|
||||
|
||||
/** Modelle aller Chats: gewaehlt, effektiv, Grund + Auswahl-Liste. */
|
||||
getContextModels(): Promise<ContextModels> {
|
||||
return _send('/context-model/list');
|
||||
},
|
||||
/** Modell fuer einen Chat setzen ('' als Modell = wieder die Einstellung). */
|
||||
setContextModel(contextId: string, model: string): Promise<ContextModels> {
|
||||
return _send('/context-model/set', {
|
||||
method: 'POST',
|
||||
body: { context_id: contextId || '', model: model || '' },
|
||||
});
|
||||
},
|
||||
/** Ausweich-Modell beenden: zurueck aufs gewaehlte Modell dieses Chats. */
|
||||
contextModelBack(contextId: string): Promise<ContextModels> {
|
||||
return _send('/context-model/back-to-selected', {
|
||||
method: 'POST',
|
||||
body: { context_id: contextId || '' },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
|
||||
@@ -49,8 +49,15 @@ const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
||||
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||
let _breadcrumbDirty = false;
|
||||
|
||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
|
||||
*
|
||||
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
|
||||
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
|
||||
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
|
||||
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
|
||||
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
|
||||
export function noteBreadcrumb(scope: string, message: string): void {
|
||||
if (scope === 'app.crash-detected') return;
|
||||
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||
_breadcrumbDirty = true;
|
||||
}
|
||||
|
||||
@@ -104,11 +104,7 @@ class RVSConnection {
|
||||
// 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.detachSocket(this.ws);
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
@@ -134,6 +130,15 @@ class RVSConnection {
|
||||
this.setState('disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
|
||||
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
|
||||
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
|
||||
*/
|
||||
isSocketOpen(): boolean {
|
||||
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Nachricht an den RVS senden */
|
||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
@@ -215,7 +220,12 @@ class RVSConnection {
|
||||
this.startHeartbeat();
|
||||
};
|
||||
|
||||
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
|
||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
||||
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
|
||||
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
|
||||
// liefern und vortaeuschen, es sei alles in Ordnung.
|
||||
if (socket !== this.ws) return;
|
||||
try {
|
||||
const message: RVSMessage = JSON.parse(event.data as string);
|
||||
this.notifyMessageListeners(message);
|
||||
@@ -258,8 +268,7 @@ class RVSConnection {
|
||||
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
||||
this.clearTimers();
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
|
||||
try { this.ws.close(); } catch (_) {}
|
||||
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
|
||||
}
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
@@ -302,6 +311,25 @@ class RVSConnection {
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
|
||||
*
|
||||
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
|
||||
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
|
||||
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
|
||||
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
|
||||
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
|
||||
*/
|
||||
private detachSocket(ws: WebSocket): void {
|
||||
try {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null; // <- das fehlte
|
||||
ws.onclose = null; // verhindert doppelten Reconnect
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul.
|
||||
*
|
||||
* Zwei Faelle:
|
||||
* App war ZU → der Inhalt liegt beim Start am Intent → getShared()
|
||||
* App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare()
|
||||
*
|
||||
* Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert
|
||||
* echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr
|
||||
* lesbar (die Leseerlaubnis gilt nur fuer den Intent).
|
||||
*/
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import type { FileData } from '../components/FileUpload';
|
||||
import type { SharedItem } from '../components/ShareModal';
|
||||
|
||||
const { ShareIntent } = NativeModules;
|
||||
|
||||
export interface SharedPayload {
|
||||
text: string;
|
||||
items: SharedItem[];
|
||||
}
|
||||
|
||||
const EMPTY: SharedPayload = { text: '', items: [] };
|
||||
|
||||
/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */
|
||||
export async function getShared(): Promise<SharedPayload> {
|
||||
if (!ShareIntent?.getSharedData) return EMPTY;
|
||||
try {
|
||||
const r = await ShareIntent.getSharedData();
|
||||
return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] };
|
||||
} catch (e) {
|
||||
console.warn('[Share] getSharedData fehlgeschlagen:', e);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */
|
||||
export function onShare(cb: (p: SharedPayload) => void): () => void {
|
||||
if (!ShareIntent) return () => {};
|
||||
const emitter = new NativeEventEmitter(ShareIntent);
|
||||
const sub = emitter.addListener('ShareReceived', (r: any) => {
|
||||
cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] });
|
||||
});
|
||||
return () => { try { sub.remove(); } catch (_) {} };
|
||||
}
|
||||
|
||||
/** Zwischenkopien loeschen (nach erfolgreichem Senden). */
|
||||
export async function clearShareCache(): Promise<void> {
|
||||
try { await ShareIntent?.clearCache?.(); } catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg
|
||||
* erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die
|
||||
* Bueroklammer, inklusive Anhang-Darstellung im Chat.
|
||||
*/
|
||||
export async function toFileData(items: SharedItem[]): Promise<FileData[]> {
|
||||
const out: FileData[] = [];
|
||||
for (const it of items) {
|
||||
try {
|
||||
const base64 = await RNFS.readFile(it.path, 'base64');
|
||||
out.push({
|
||||
name: it.name || 'geteilt',
|
||||
type: it.type || 'application/octet-stream',
|
||||
size: it.size || 0,
|
||||
base64,
|
||||
uri: 'file://' + it.path,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default { getShared, onShare, clearShareCache, toFileData };
|
||||
+938
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,950 @@
|
||||
"""ARIAs E-Mail: senden (SMTP) und lesen (IMAP).
|
||||
|
||||
Drei Grundsaetze, aus denen sich der Rest ergibt:
|
||||
|
||||
1. Die Zugangsdaten sieht ARIA nie.
|
||||
ARIAs Claude-Container (proxy) hat /shared eingehaengt — alles unter
|
||||
/shared/config kann sie lesen. Deshalb liegen Konfiguration und Passwort im
|
||||
Brain unter /data, das dort NICHT eingehaengt ist. Keine Schnittstelle gibt
|
||||
das Passwort je zurueck (nur `password_set: true`), kein Tool-Ergebnis
|
||||
enthaelt es, der Brain-Export laesst die Geheimnis-Datei aus.
|
||||
|
||||
2. Die Whitelist ist eine harte Grenze, kein Prompt-Hinweis.
|
||||
Geprueft wird hier im Sendepfad, fuer JEDEN Empfaenger (To, Cc, Bcc). Leere
|
||||
Liste = ARIA kann nirgendwohin senden. Aendern darf die Liste nur, wer den
|
||||
Admin-Schluessel hat (/data/email_admin.key) — den liest die Diagnostic,
|
||||
ARIAs Container nicht. Wichtig, weil eingehende Mails Text enthalten, den
|
||||
ARIA liest: eine Mail mit "schick das an X" darf nicht reichen.
|
||||
|
||||
3. Eingehende Mails sind Daten, keine Anweisungen.
|
||||
Tool-Ergebnisse mit Mail-Inhalt sind entsprechend markiert.
|
||||
|
||||
Grenze, die man kennen sollte: ARIAs Container hat einen SSH-Schluessel auf die
|
||||
VM. Legt sie es gezielt darauf an, kommt sie als root an jede Datei. Dieses
|
||||
Modul sorgt dafuer, dass die Zugangsdaten nirgends von selbst auftauchen — es
|
||||
ist kein Schutz gegen einen bewussten Einbruch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import html as html_mod
|
||||
import imaplib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import smtplib
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesParser
|
||||
from email.utils import formataddr, formatdate, getaddresses, make_msgid, parseaddr
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = Path(os.environ.get("ARIA_MAIL_DATA_DIR", "/data"))
|
||||
CONFIG_FILE = DATA_DIR / "email.json" # Server, Ports, Whitelist, Benutzername
|
||||
SECRET_FILE = DATA_DIR / "email_secret.json" # nur Passwoerter — vom Export ausgenommen
|
||||
ADMIN_KEY_FILE = DATA_DIR / "email_admin.key" # Schreibrecht auf die Konfiguration
|
||||
|
||||
# Wohin ARIA Anhaenge ablegt bzw. woher sie welche nehmen darf. Alles andere
|
||||
# (vor allem /data, wo die Passwoerter liegen) ist tabu — auch ueber Symlinks.
|
||||
UPLOAD_ROOT = Path(os.environ.get("ARIA_MAIL_UPLOAD_ROOT", "/shared/uploads"))
|
||||
ATTACH_ROOTS = [Path(p) for p in os.environ.get(
|
||||
"ARIA_MAIL_ATTACH_ROOTS", "/shared/uploads:/shared/projects").split(":") if p]
|
||||
|
||||
MAX_SEND_BYTES = 25 * 1024 * 1024 # gaengiges Limit der meisten Mailserver
|
||||
MAX_BODY_CHARS = 20000 # so viel Mailtext bekommt ARIA pro Mail
|
||||
NET_TIMEOUT = 30
|
||||
|
||||
ENCRYPTIONS = ("ssl", "starttls", "none")
|
||||
|
||||
DEFAULTS = {
|
||||
"from_name": "ARIA",
|
||||
"from_address": "",
|
||||
"username": "",
|
||||
"imap_host": "", "imap_port": 993, "imap_encryption": "ssl",
|
||||
"smtp_host": "", "smtp_port": 465, "smtp_encryption": "ssl",
|
||||
# Abweichender SMTP-Login (selten, z.B. Relay). Leer = wie IMAP.
|
||||
"smtp_username": "",
|
||||
"verify_tls": True,
|
||||
"save_sent": True, # Kopie jeder gesendeten Mail in "Gesendet" ablegen
|
||||
"whitelist": [],
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class MailError(Exception):
|
||||
"""Fehler, dessen Text ARIA/Stefan sehen duerfen (kein Geheimnis darin)."""
|
||||
|
||||
|
||||
# ── Speicher ────────────────────────────────────────────────────────
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
try:
|
||||
if path.exists():
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning("[mail] %s nicht lesbar: %s", path.name, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_private(path: Path, data: dict) -> None:
|
||||
"""Atomar und mit 0600 — die Datei existiert nie mit offeneren Rechten."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = dict(DEFAULTS)
|
||||
cfg.update({k: v for k, v in _read_json(CONFIG_FILE).items() if k in DEFAULTS})
|
||||
cfg["whitelist"] = [str(p).strip() for p in (cfg.get("whitelist") or []) if str(p).strip()]
|
||||
return cfg
|
||||
|
||||
|
||||
def _secrets() -> dict:
|
||||
return _read_json(SECRET_FILE)
|
||||
|
||||
|
||||
def public_config() -> dict:
|
||||
"""Konfiguration fuer die Diagnostic. Passwoerter NUR als Ja/Nein."""
|
||||
cfg = load_config()
|
||||
sec = _secrets()
|
||||
cfg["password_set"] = bool(sec.get("password"))
|
||||
cfg["smtp_password_set"] = bool(sec.get("smtp_password"))
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config(patch: dict) -> dict:
|
||||
"""Konfiguration aendern. Passwort-Felder:
|
||||
password / smtp_password — nicht leer = neu setzen, leer/fehlt = unveraendert
|
||||
clear_password / clear_smtp_password — True = loeschen
|
||||
"""
|
||||
with _lock:
|
||||
cfg = load_config()
|
||||
for key in DEFAULTS:
|
||||
if key not in patch:
|
||||
continue
|
||||
val = patch[key]
|
||||
if key in ("imap_port", "smtp_port"):
|
||||
try:
|
||||
val = int(val)
|
||||
except (TypeError, ValueError):
|
||||
raise MailError(f"{key}: keine Zahl")
|
||||
if not 1 <= val <= 65535:
|
||||
raise MailError(f"{key}: {val} ist kein gueltiger Port")
|
||||
elif key in ("imap_encryption", "smtp_encryption"):
|
||||
val = str(val or "").lower()
|
||||
if val not in ENCRYPTIONS:
|
||||
raise MailError(f"{key}: erlaubt sind {', '.join(ENCRYPTIONS)}")
|
||||
elif key in ("verify_tls", "save_sent"):
|
||||
val = bool(val)
|
||||
elif key == "whitelist":
|
||||
if isinstance(val, str):
|
||||
val = re.split(r"[\s,;]+", val)
|
||||
val = [str(p).strip() for p in (val or []) if str(p).strip()]
|
||||
for p in val:
|
||||
_check_pattern(p)
|
||||
else:
|
||||
val = _no_newlines(str(val or "").strip())
|
||||
cfg[key] = val
|
||||
_write_private(CONFIG_FILE, cfg)
|
||||
|
||||
sec = _secrets()
|
||||
changed = False
|
||||
for field in ("password", "smtp_password"):
|
||||
if patch.get("clear_" + field):
|
||||
changed |= sec.pop(field, None) is not None
|
||||
elif patch.get(field):
|
||||
sec[field] = str(patch[field])
|
||||
changed = True
|
||||
if changed:
|
||||
_write_private(SECRET_FILE, sec)
|
||||
# Bewusst ohne Werte geloggt.
|
||||
logger.info("[mail] Konfiguration gespeichert (Whitelist: %d Eintraege)", len(cfg["whitelist"]))
|
||||
return public_config()
|
||||
|
||||
|
||||
def admin_key() -> str:
|
||||
"""Schreibschluessel fuer die Konfiguration. Wird beim ersten Zugriff
|
||||
erzeugt. Die Diagnostic liest ihn aus ./aria-data/brain/data — ARIAs
|
||||
Container hat diesen Pfad nicht."""
|
||||
with _lock:
|
||||
try:
|
||||
if ADMIN_KEY_FILE.exists():
|
||||
k = ADMIN_KEY_FILE.read_text(encoding="utf-8").strip()
|
||||
if k:
|
||||
return k
|
||||
except Exception:
|
||||
pass
|
||||
k = secrets.token_urlsafe(32)
|
||||
ADMIN_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(ADMIN_KEY_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(k)
|
||||
return k
|
||||
|
||||
|
||||
def check_admin(provided: Optional[str]) -> bool:
|
||||
if not provided:
|
||||
return False
|
||||
return hmac.compare_digest(provided.strip(), admin_key())
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Was ARIA ueber ihr Postfach wissen darf — ohne Zugangsdaten."""
|
||||
cfg = load_config()
|
||||
sec = _secrets()
|
||||
can_login = bool(cfg["username"] and sec.get("password"))
|
||||
return {
|
||||
"configured_imap": bool(cfg["imap_host"] and can_login),
|
||||
"configured_smtp": bool(cfg["smtp_host"] and (can_login or (
|
||||
cfg["smtp_username"] and sec.get("smtp_password")))),
|
||||
"from_address": cfg["from_address"],
|
||||
"from_name": cfg["from_name"],
|
||||
"whitelist": list(cfg["whitelist"]),
|
||||
}
|
||||
|
||||
|
||||
# ── Whitelist ───────────────────────────────────────────────────────
|
||||
|
||||
_ADDR_RE = re.compile(r"^[^@\s<>()\[\],;:\"]+@([a-z0-9-]+(\.[a-z0-9-]+)+)$", re.IGNORECASE)
|
||||
_DOMAIN_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)+$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _pattern(p: str) -> tuple:
|
||||
"""Muster zerlegen → (art, wert). Wirft bei ungueltigen Mustern.
|
||||
|
||||
Genau EINE Schreibweise: name@domain, Platzhalter nur als ganzes Teil.
|
||||
stefan@hacker-net.de → ('adresse', 'stefan@hacker-net.de')
|
||||
*@hacker-net.de → ('domain', 'hacker-net.de')
|
||||
*@*.hacker-net.de → ('subdomain', 'hacker-net.de')
|
||||
*@* → ('alles', '')
|
||||
|
||||
Kurzformen wie '*hacker-net.de' oder 'hacker-net.de' werden abgelehnt:
|
||||
'*hacker-net.de' liest sich, als passte es auch auf 'evilhacker-net.de'.
|
||||
In einer Sicherheitsliste soll niemand raten muessen, was ein Eintrag tut.
|
||||
"""
|
||||
q = (p or "").strip().lower()
|
||||
if q == "*@*":
|
||||
return "alles", ""
|
||||
if q.count("@") != 1:
|
||||
hint = q.lstrip("*@.")
|
||||
raise MailError(f"Whitelist: '{p}' — Schreibweise ist name@domain"
|
||||
+ (f", fuer alle Adressen der Domain: *@{hint}" if _DOMAIN_RE.match(hint) else ""))
|
||||
local, dom = q.split("@")
|
||||
if local == "*":
|
||||
if dom.startswith("*."):
|
||||
if _DOMAIN_RE.match(dom[2:]):
|
||||
return "subdomain", dom[2:]
|
||||
elif _DOMAIN_RE.match(dom):
|
||||
return "domain", dom
|
||||
raise MailError(f"Whitelist: '{p}' — Domain ungueltig ('*' nur als *@domain oder *@*.domain)")
|
||||
if "*" in q or not _ADDR_RE.match(q):
|
||||
raise MailError(f"Whitelist: '{p}' ist keine gueltige Adresse")
|
||||
return "adresse", q
|
||||
|
||||
|
||||
def _check_pattern(p: str) -> None:
|
||||
"""Ungueltige Muster schon beim Speichern abweisen — ein Tippfehler soll
|
||||
nicht still 'nichts erlaubt' oder gar 'zu viel erlaubt' bedeuten."""
|
||||
_pattern(p)
|
||||
|
||||
|
||||
def address_allowed(address: str, whitelist: Iterable[str]) -> bool:
|
||||
"""Darf an diese Adresse gesendet werden? Muster siehe _pattern().
|
||||
|
||||
Verglichen wird die Domain als Ganzes, nie ein Textende: '*@hacker-net.de'
|
||||
trifft weder 'evilhacker-net.de' noch 'mail.hacker-net.de'.
|
||||
"""
|
||||
addr = (address or "").strip().lower()
|
||||
m = _ADDR_RE.match(addr)
|
||||
if not m:
|
||||
return False
|
||||
domain = m.group(1)
|
||||
for raw in whitelist or ():
|
||||
try:
|
||||
kind, val = _pattern(str(raw))
|
||||
except MailError:
|
||||
continue # kaputtes Muster erlaubt nichts
|
||||
if kind == "alles":
|
||||
return True
|
||||
if kind == "adresse" and addr == val:
|
||||
return True
|
||||
if kind == "domain" and domain == val:
|
||||
return True
|
||||
# Mit Punkt davor: "mail.hacker-net.de" endet auf ".hacker-net.de",
|
||||
# "evilhacker-net.de" nicht.
|
||||
if kind == "subdomain" and domain.endswith("." + val):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_recipients(values) -> list:
|
||||
"""Liste aus Strings/Listen → reine Adressen. Namen ("Max <m@x.de>") sind
|
||||
erlaubt, zaehlen aber nur ueber die Adresse."""
|
||||
if not values:
|
||||
return []
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
out = []
|
||||
for name, addr in getaddresses([str(v) for v in values]):
|
||||
addr = addr.strip()
|
||||
if addr:
|
||||
out.append((name.strip(), addr))
|
||||
return out
|
||||
|
||||
|
||||
def check_recipients(to, cc=None, bcc=None) -> tuple:
|
||||
"""(erlaubt, abgelehnt) ueber ALLE Empfaenger. Ein einziger abgelehnter
|
||||
blockiert die ganze Mail — teilweises Senden waere schlimmer als keins."""
|
||||
wl = load_config()["whitelist"]
|
||||
allowed, rejected = [], []
|
||||
for name, addr in _parse_recipients(to) + _parse_recipients(cc) + _parse_recipients(bcc):
|
||||
(allowed if address_allowed(addr, wl) else rejected).append(addr)
|
||||
return allowed, rejected
|
||||
|
||||
|
||||
# ── Pfade ───────────────────────────────────────────────────────────
|
||||
|
||||
def _safe_attach_path(p: str) -> Path:
|
||||
"""Nur Dateien unter den erlaubten Wurzeln. realpath zuerst: ein Symlink in
|
||||
/shared/uploads, der auf /data/email_secret.json zeigt, wuerde sonst im
|
||||
Brain-Container aufgeloest — und das Passwort als Anhang verschickt."""
|
||||
if not p:
|
||||
raise MailError("Leerer Dateipfad")
|
||||
real = Path(os.path.realpath(p))
|
||||
for root in ATTACH_ROOTS:
|
||||
root_real = Path(os.path.realpath(str(root)))
|
||||
if real == root_real or root_real in real.parents:
|
||||
if not real.is_file():
|
||||
raise MailError(f"{p}: keine Datei")
|
||||
return real
|
||||
raise MailError(f"{p}: nur Dateien unter {', '.join(str(r) for r in ATTACH_ROOTS)} erlaubt")
|
||||
|
||||
|
||||
def _no_newlines(s: str) -> str:
|
||||
"""Kopfzeilen ohne Zeilenumbruch — sonst liessen sich Header einschmuggeln."""
|
||||
return re.sub(r"[\r\n]+", " ", s or "")
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
t = re.sub(r"(?is)<(script|style|head)[^>]*>.*?</\1>", "", html or "")
|
||||
t = re.sub(r"(?i)<br\s*/?>", "\n", t)
|
||||
t = re.sub(r"(?i)</(p|div|tr|h[1-6]|li)>", "\n", t)
|
||||
t = re.sub(r"(?s)<[^>]+>", "", t)
|
||||
t = html_mod.unescape(t)
|
||||
t = re.sub(r"[ \t]+", " ", t)
|
||||
return re.sub(r"\n\s*\n+", "\n\n", t).strip()
|
||||
|
||||
|
||||
# ── Nachricht bauen ─────────────────────────────────────────────────
|
||||
|
||||
def build_message(cfg: dict, to, subject: str, text: str = "", html: str = "",
|
||||
cc=None, attachments: Optional[list] = None,
|
||||
inline_images: Optional[list] = None,
|
||||
in_reply_to: str = "", references: str = "") -> EmailMessage:
|
||||
"""Baut die MIME-Struktur:
|
||||
|
||||
multipart/mixed (nur wenn Anhaenge)
|
||||
multipart/alternative
|
||||
text/plain (immer — aus HTML erzeugt, wenn keiner da)
|
||||
multipart/related (nur wenn eingebettete Bilder)
|
||||
text/html
|
||||
image/* Content-ID <cid>
|
||||
application/... Anhaenge
|
||||
|
||||
Bcc steht absichtlich NICHT im Header — er geht nur in die Umschlag-
|
||||
Empfaenger beim Senden.
|
||||
"""
|
||||
if not (text or html):
|
||||
raise MailError("Leere Mail: text oder html angeben")
|
||||
if inline_images and not html:
|
||||
raise MailError("Eingebettete Bilder brauchen html (<img src=\"cid:...\">)")
|
||||
|
||||
msg = EmailMessage(policy=policy.SMTP)
|
||||
msg["From"] = formataddr((_no_newlines(cfg["from_name"]), cfg["from_address"]))
|
||||
msg["To"] = ", ".join(formataddr(r) for r in _parse_recipients(to))
|
||||
if cc:
|
||||
msg["Cc"] = ", ".join(formataddr(r) for r in _parse_recipients(cc))
|
||||
msg["Subject"] = _no_newlines(subject or "")
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
domain = (cfg["from_address"].split("@", 1)[1] if "@" in cfg["from_address"] else None)
|
||||
msg["Message-ID"] = make_msgid(domain=domain)
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = _no_newlines(in_reply_to)
|
||||
msg["References"] = _no_newlines((references + " " + in_reply_to).strip())
|
||||
|
||||
msg.set_content(text or _html_to_text(html))
|
||||
if html:
|
||||
msg.add_alternative(html, subtype="html")
|
||||
if inline_images:
|
||||
html_part = msg.get_payload()[-1]
|
||||
for img in inline_images:
|
||||
path = _safe_attach_path(img.get("path", ""))
|
||||
cid = (img.get("cid") or path.name).strip("<>")
|
||||
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
maintype, subtype = ctype.split("/", 1)
|
||||
if maintype != "image":
|
||||
raise MailError(f"{path.name}: eingebettet werden nur Bilder")
|
||||
html_part.add_related(path.read_bytes(), maintype=maintype, subtype=subtype,
|
||||
cid=f"<{cid}>", filename=path.name,
|
||||
disposition="inline")
|
||||
for p in attachments or []:
|
||||
path = _safe_attach_path(p)
|
||||
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
maintype, subtype = ctype.split("/", 1)
|
||||
msg.add_attachment(path.read_bytes(), maintype=maintype, subtype=subtype,
|
||||
filename=path.name)
|
||||
return msg
|
||||
|
||||
|
||||
# ── Verbindungen ────────────────────────────────────────────────────
|
||||
|
||||
def _tls_context(cfg: dict) -> ssl.SSLContext:
|
||||
ctx = ssl.create_default_context()
|
||||
if not cfg.get("verify_tls", True):
|
||||
# Fuer eigene Server mit selbstsigniertem Zertifikat. Bewusste Wahl in
|
||||
# der Diagnostic — verschluesselt bleibt es trotzdem.
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def _login_error(proto: str, exc: Exception) -> MailError:
|
||||
# Fehlertexte der Server enthalten nie das Passwort — trotzdem nur den
|
||||
# Typ + gekuerzte Meldung weitergeben.
|
||||
return MailError(f"{proto}: {type(exc).__name__}: {str(exc)[:200]}")
|
||||
|
||||
|
||||
def _smtp_open(cfg: dict):
|
||||
sec = _secrets()
|
||||
user = cfg["smtp_username"] or cfg["username"]
|
||||
pw = sec.get("smtp_password") if cfg["smtp_username"] else sec.get("password")
|
||||
if not (cfg["smtp_host"] and user and pw):
|
||||
raise MailError("SMTP ist nicht eingerichtet (Diagnostic → Einstellungen → ARIA E-Mail)")
|
||||
enc = cfg["smtp_encryption"]
|
||||
try:
|
||||
if enc == "ssl":
|
||||
s = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=NET_TIMEOUT,
|
||||
context=_tls_context(cfg))
|
||||
else:
|
||||
s = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=NET_TIMEOUT)
|
||||
s.ehlo()
|
||||
if enc == "starttls":
|
||||
s.starttls(context=_tls_context(cfg))
|
||||
s.ehlo()
|
||||
s.login(user, pw)
|
||||
return s
|
||||
except MailError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise _login_error("SMTP", exc)
|
||||
|
||||
|
||||
def _imap_open(cfg: dict):
|
||||
sec = _secrets()
|
||||
if not (cfg["imap_host"] and cfg["username"] and sec.get("password")):
|
||||
raise MailError("IMAP ist nicht eingerichtet (Diagnostic → Einstellungen → ARIA E-Mail)")
|
||||
enc = cfg["imap_encryption"]
|
||||
try:
|
||||
if enc == "ssl":
|
||||
m = imaplib.IMAP4_SSL(cfg["imap_host"], cfg["imap_port"],
|
||||
ssl_context=_tls_context(cfg), timeout=NET_TIMEOUT)
|
||||
else:
|
||||
m = imaplib.IMAP4(cfg["imap_host"], cfg["imap_port"], timeout=NET_TIMEOUT)
|
||||
if enc == "starttls":
|
||||
m.starttls(ssl_context=_tls_context(cfg))
|
||||
m.login(cfg["username"], sec["password"])
|
||||
return m
|
||||
except MailError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise _login_error("IMAP", exc)
|
||||
|
||||
|
||||
def _imap_close(m) -> None:
|
||||
try:
|
||||
m.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_connection() -> dict:
|
||||
"""Login an beiden Servern pruefen, ohne etwas zu senden."""
|
||||
cfg = load_config()
|
||||
out = {}
|
||||
for proto, opener, closer in (("imap", _imap_open, _imap_close),
|
||||
("smtp", _smtp_open, lambda s: s.quit())):
|
||||
try:
|
||||
conn = opener(cfg)
|
||||
try:
|
||||
closer(conn)
|
||||
except Exception:
|
||||
pass
|
||||
out[proto] = {"ok": True}
|
||||
except MailError as exc:
|
||||
out[proto] = {"ok": False, "error": str(exc)}
|
||||
return out
|
||||
|
||||
|
||||
# ── Senden ──────────────────────────────────────────────────────────
|
||||
|
||||
def send(to, subject: str, text: str = "", html: str = "", cc=None, bcc=None,
|
||||
attachments: Optional[list] = None, inline_images: Optional[list] = None,
|
||||
reply_to_uid: str = "", reply_folder: str = "INBOX") -> dict:
|
||||
cfg = load_config()
|
||||
if not cfg["from_address"]:
|
||||
raise MailError("Keine Absender-Adresse eingerichtet")
|
||||
recipients = _parse_recipients(to) + _parse_recipients(cc) + _parse_recipients(bcc)
|
||||
if not recipients:
|
||||
raise MailError("Kein Empfaenger")
|
||||
if not cfg["whitelist"]:
|
||||
raise MailError("Die Whitelist ist leer — ich darf an niemanden senden. "
|
||||
"Stefan traegt erlaubte Adressen in der Diagnostic ein.")
|
||||
_, rejected = check_recipients(to, cc, bcc)
|
||||
if rejected:
|
||||
raise MailError("Nicht auf der Whitelist: " + ", ".join(rejected)
|
||||
+ " — nichts gesendet. Erlaubt: " + ", ".join(cfg["whitelist"]))
|
||||
|
||||
in_reply_to = references = ""
|
||||
if reply_to_uid:
|
||||
orig = _fetch_message(reply_folder, reply_to_uid, mark_read=False)
|
||||
in_reply_to = orig.get("Message-ID", "") or ""
|
||||
references = orig.get("References", "") or ""
|
||||
|
||||
msg = build_message(cfg, to, subject, text=text, html=html, cc=cc,
|
||||
attachments=attachments, inline_images=inline_images,
|
||||
in_reply_to=in_reply_to, references=references)
|
||||
raw = msg.as_bytes()
|
||||
if len(raw) > MAX_SEND_BYTES:
|
||||
raise MailError(f"Mail ist {len(raw) // (1024 * 1024)} MB gross — Grenze "
|
||||
f"{MAX_SEND_BYTES // (1024 * 1024)} MB")
|
||||
|
||||
s = _smtp_open(cfg)
|
||||
try:
|
||||
s.send_message(msg, from_addr=cfg["from_address"],
|
||||
to_addrs=[a for _, a in recipients])
|
||||
except Exception as exc:
|
||||
raise MailError(f"SMTP-Versand: {type(exc).__name__}: {str(exc)[:200]}")
|
||||
finally:
|
||||
try:
|
||||
s.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
saved = ""
|
||||
if cfg.get("save_sent"):
|
||||
try:
|
||||
saved = _append_sent(cfg, raw)
|
||||
except Exception as exc:
|
||||
# Gesendet ist gesendet — die Kopie ist Komfort, kein Fehler.
|
||||
logger.warning("[mail] Kopie in Gesendet fehlgeschlagen: %s", exc)
|
||||
logger.info("[mail] gesendet an %d Empfaenger, %d Byte", len(recipients), len(raw))
|
||||
return {"ok": True, "message_id": msg["Message-ID"], "recipients": [a for _, a in recipients],
|
||||
"bytes": len(raw), "saved_to": saved}
|
||||
|
||||
|
||||
def _find_sent_folder(m) -> str:
|
||||
typ, data = m.list()
|
||||
names = []
|
||||
for line in data or []:
|
||||
if not line:
|
||||
continue
|
||||
s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
|
||||
name = _parse_list_name(s)
|
||||
if "\\Sent" in s:
|
||||
return name
|
||||
names.append(name)
|
||||
for cand in ("Sent", "Gesendet", "INBOX.Sent", "Sent Items", "Gesendete Objekte",
|
||||
"INBOX.Gesendet"):
|
||||
for n in names:
|
||||
if n.lower() == cand.lower():
|
||||
return n
|
||||
return ""
|
||||
|
||||
|
||||
SENT_FALLBACK = "Gesendet"
|
||||
|
||||
|
||||
def _append_sent(cfg: dict, raw: bytes) -> str:
|
||||
m = _imap_open(cfg)
|
||||
try:
|
||||
folder = _find_sent_folder(m)
|
||||
if not folder:
|
||||
# Kein Gesendet-Ordner (frisches Postfach, manche Hoster): anlegen.
|
||||
# Sonst ginge die Kopie still verloren — und Stefan saehe in der
|
||||
# Diagnostic nicht, was ARIA verschickt hat.
|
||||
typ, _ = m.create(_quote(SENT_FALLBACK))
|
||||
if typ != "OK":
|
||||
logger.warning("[mail] Ordner %r nicht anlegbar — keine Kopie", SENT_FALLBACK)
|
||||
return ""
|
||||
folder = SENT_FALLBACK
|
||||
m.append(_quote(folder), "(\\Seen)", imaplib.Time2Internaldate(time.time()), raw)
|
||||
return folder
|
||||
finally:
|
||||
_imap_close(m)
|
||||
|
||||
|
||||
# ── Lesen ───────────────────────────────────────────────────────────
|
||||
|
||||
_LIST_RE = re.compile(r'\((?P<flags>[^)]*)\)\s+(?P<delim>"[^"]*"|NIL)\s+(?P<name>.+)$')
|
||||
|
||||
|
||||
def _parse_list_name(line: str) -> str:
|
||||
m = _LIST_RE.match(line.strip())
|
||||
if not m:
|
||||
return line.strip()
|
||||
name = m.group("name").strip()
|
||||
if name.startswith('"') and name.endswith('"'):
|
||||
name = name[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
return name
|
||||
|
||||
|
||||
def imap_utf7_decode(name: str) -> str:
|
||||
"""'Entw&APw-rfe' → 'Entwürfe'. IMAP kodiert Ordnernamen in modifiziertem
|
||||
UTF-7 (RFC 3501). Nur fuer die ANZEIGE — an den Server geht der Rohname."""
|
||||
import base64
|
||||
def dec(m):
|
||||
chunk = m.group(1)
|
||||
if chunk == "":
|
||||
return "&"
|
||||
b = chunk.replace(",", "/")
|
||||
b += "=" * (-len(b) % 4)
|
||||
try:
|
||||
return base64.b64decode(b).decode("utf-16-be")
|
||||
except Exception:
|
||||
return m.group(0)
|
||||
return re.sub(r"&([A-Za-z0-9+,]*)-", dec, name or "")
|
||||
|
||||
|
||||
def _quote(folder: str) -> str:
|
||||
return '"' + folder.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
_SENT_NAMES = {"sent", "gesendet", "inbox.sent", "sent items", "gesendete objekte",
|
||||
"inbox.gesendet", "gesendete elemente"}
|
||||
|
||||
|
||||
def list_folders() -> list:
|
||||
"""[{name (roh, fuer den Server), label (lesbar), role}], Posteingang und
|
||||
Gesendet zuerst. role: 'inbox' | 'sent' | ''. Gesendet wird ueber das
|
||||
Server-Flag \\Sent erkannt, sonst ueber die ueblichen Namen."""
|
||||
m = _imap_open(load_config())
|
||||
try:
|
||||
typ, data = m.list()
|
||||
out = []
|
||||
for line in data or []:
|
||||
if not line:
|
||||
continue
|
||||
s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
|
||||
if "\\Noselect" in s:
|
||||
continue
|
||||
name = _parse_list_name(s)
|
||||
label = imap_utf7_decode(name)
|
||||
lm = _LIST_RE.match(s.strip())
|
||||
delim = (lm.group("delim").strip('"') if lm and lm.group("delim") != "NIL" else "")
|
||||
role = ("inbox" if name.upper() == "INBOX" else
|
||||
"sent" if "\\Sent" in s or label.lower() in _SENT_NAMES else "")
|
||||
# Unterordner: Tiefe + letzter Namensteil fuer die Baumansicht.
|
||||
parts = label.split(delim) if delim else [label]
|
||||
out.append({"name": name, "label": label, "role": role,
|
||||
"depth": len(parts) - 1, "short": parts[-1]})
|
||||
# Posteingang + Gesendet nach oben; der Rest nach vollem Pfad, damit
|
||||
# Unterordner direkt unter ihrem Elternordner stehen.
|
||||
order = {"inbox": 0, "sent": 1}
|
||||
out.sort(key=lambda f: (order.get(f["role"], 2), f["label"].lower()))
|
||||
return out
|
||||
finally:
|
||||
_imap_close(m)
|
||||
|
||||
|
||||
def _uid_ok(uid) -> str:
|
||||
u = str(uid or "").strip()
|
||||
if not u.isdigit():
|
||||
raise MailError(f"Ungueltige UID: {uid!r}")
|
||||
return u
|
||||
|
||||
|
||||
def _select(m, folder: str, readonly: bool) -> None:
|
||||
typ, data = m.select(_quote(folder or "INBOX"), readonly=readonly)
|
||||
if typ != "OK":
|
||||
raise MailError(f"Ordner {folder!r} nicht gefunden")
|
||||
|
||||
|
||||
def list_messages(folder: str = "INBOX", limit: int = 20, offset: int = 0,
|
||||
unread_only: bool = False, search: str = "") -> dict:
|
||||
limit = max(1, min(int(limit or 20), 100))
|
||||
offset = max(0, int(offset or 0))
|
||||
m = _imap_open(load_config())
|
||||
try:
|
||||
_select(m, folder, readonly=True)
|
||||
if search:
|
||||
m.literal = search.encode("utf-8")
|
||||
crit = ["CHARSET", "UTF-8"] + (["UNSEEN"] if unread_only else []) + ["TEXT"]
|
||||
typ, data = m.uid("SEARCH", *crit)
|
||||
else:
|
||||
typ, data = m.uid("SEARCH", "UNSEEN" if unread_only else "ALL")
|
||||
uids = (data[0] or b"").split() if data else []
|
||||
uids = sorted((int(u) for u in uids), reverse=True)
|
||||
total = len(uids)
|
||||
page = uids[offset:offset + limit]
|
||||
items = []
|
||||
if page:
|
||||
fields = "FROM TO CC SUBJECT DATE CONTENT-TYPE MESSAGE-ID"
|
||||
typ, data = m.uid("FETCH", ",".join(str(u) for u in page),
|
||||
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({fields})])")
|
||||
by_uid = {}
|
||||
last = None
|
||||
# imaplib liefert pro Mail ein Tupel (Meta, Header-Bytes) und danach
|
||||
# ein Bytes-Stueck mit dem Rest der Zeile. Manche Server (Dovecot je
|
||||
# nach Reihenfolge, Exchange) schreiben FLAGS erst DORT hin — nur im
|
||||
# Meta-Teil zu suchen hiesse: jede Mail "ungelesen".
|
||||
for part in data or []:
|
||||
if isinstance(part, tuple):
|
||||
meta = part[0].decode("utf-8", "replace")
|
||||
um = re.search(r"UID (\d+)", meta)
|
||||
if not um:
|
||||
last = None
|
||||
continue
|
||||
hdr = BytesParser(policy=policy.default).parsebytes(part[1], headersonly=True)
|
||||
sm = re.search(r"RFC822\.SIZE (\d+)", meta)
|
||||
ctype = str(hdr.get("Content-Type", "") or "").lower()
|
||||
last = int(um.group(1))
|
||||
by_uid[last] = {
|
||||
"uid": um.group(1),
|
||||
"from": str(hdr.get("From", "") or ""),
|
||||
"to": str(hdr.get("To", "") or ""),
|
||||
"subject": str(hdr.get("Subject", "") or ""),
|
||||
"date": str(hdr.get("Date", "") or ""),
|
||||
"seen": False,
|
||||
"size": int(sm.group(1)) if sm else 0,
|
||||
"maybe_attachments": "multipart/mixed" in ctype,
|
||||
}
|
||||
meta_flags = re.search(r"FLAGS \(([^)]*)\)", meta)
|
||||
if meta_flags:
|
||||
by_uid[last]["seen"] = "\\Seen" in meta_flags.group(1)
|
||||
elif isinstance(part, bytes) and last is not None:
|
||||
tail_flags = re.search(r"FLAGS \(([^)]*)\)", part.decode("utf-8", "replace"))
|
||||
if tail_flags:
|
||||
by_uid[last]["seen"] = "\\Seen" in tail_flags.group(1)
|
||||
items = [by_uid[u] for u in page if u in by_uid]
|
||||
return {"folder": folder, "total": total, "offset": offset, "messages": items}
|
||||
finally:
|
||||
_imap_close(m)
|
||||
|
||||
|
||||
def _fetch_message(folder: str, uid, mark_read: bool):
|
||||
u = _uid_ok(uid)
|
||||
m = _imap_open(load_config())
|
||||
try:
|
||||
_select(m, folder, readonly=not mark_read)
|
||||
typ, data = m.uid("FETCH", u, "(BODY.PEEK[])")
|
||||
raw = next((p[1] for p in (data or []) if isinstance(p, tuple)), None)
|
||||
if raw is None:
|
||||
raise MailError(f"Mail UID {u} in {folder!r} nicht gefunden")
|
||||
if mark_read:
|
||||
m.uid("STORE", u, "+FLAGS", "(\\Seen)")
|
||||
return BytesParser(policy=policy.default).parsebytes(raw)
|
||||
finally:
|
||||
_imap_close(m)
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
n = re.sub(r"[^\w.\- ]+", "_", os.path.basename(name or "")).strip(" .")
|
||||
return n[:120] or "anhang"
|
||||
|
||||
|
||||
def _bodies(msg) -> tuple:
|
||||
text_part = msg.get_body(preferencelist=("plain",))
|
||||
html_part = msg.get_body(preferencelist=("html",))
|
||||
text = text_part.get_content() if text_part is not None else ""
|
||||
html = html_part.get_content() if html_part is not None else ""
|
||||
return text, html
|
||||
|
||||
|
||||
def read_message(folder: str, uid, mark_read: bool = True) -> dict:
|
||||
"""Fuer ARIA: Kopfzeilen, Text (HTML → Text, wenn es nur HTML gibt) und
|
||||
Anhaenge — die landen unter /shared/uploads/email/, damit ARIA sie wie
|
||||
jeden anderen Upload ansehen kann."""
|
||||
msg = _fetch_message(folder, uid, mark_read)
|
||||
text, html = _bodies(msg)
|
||||
body = text or _html_to_text(html)
|
||||
truncated = len(body) > MAX_BODY_CHARS
|
||||
attachments = []
|
||||
target = UPLOAD_ROOT / "email" / f"{_safe_name(folder)}-{_uid_ok(uid)}"
|
||||
for i, part in enumerate(msg.iter_attachments()):
|
||||
name = _safe_name(part.get_filename() or f"anhang-{i + 1}")
|
||||
data = part.get_payload(decode=True) or b""
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
path = target / name
|
||||
path.write_bytes(data)
|
||||
attachments.append({"name": name, "path": str(path), "size": len(data),
|
||||
"type": part.get_content_type()})
|
||||
return {
|
||||
"uid": _uid_ok(uid), "folder": folder,
|
||||
"from": str(msg.get("From", "") or ""), "to": str(msg.get("To", "") or ""),
|
||||
"cc": str(msg.get("Cc", "") or ""), "subject": str(msg.get("Subject", "") or ""),
|
||||
"date": str(msg.get("Date", "") or ""),
|
||||
"message_id": str(msg.get("Message-ID", "") or ""),
|
||||
"body": body[:MAX_BODY_CHARS], "truncated": truncated,
|
||||
"had_html": bool(html), "attachments": attachments,
|
||||
}
|
||||
|
||||
|
||||
def view_message(folder: str, uid) -> dict:
|
||||
"""Fuer den Postfach-Viewer: wie read_message, aber OHNE als gelesen zu
|
||||
markieren und mit dem HTML. Eingebettete Bilder (cid:) werden zu data:-URIs,
|
||||
damit der Viewer sie zeigen kann, ohne irgendetwas nachzuladen."""
|
||||
import base64
|
||||
msg = _fetch_message(folder, uid, mark_read=False)
|
||||
text, html = _bodies(msg)
|
||||
if html:
|
||||
for part in msg.walk():
|
||||
cid = (part.get("Content-ID") or "").strip().strip("<>")
|
||||
if cid and part.get_content_maintype() == "image":
|
||||
b64 = base64.b64encode(part.get_payload(decode=True) or b"").decode("ascii")
|
||||
html = html.replace(f"cid:{cid}", f"data:{part.get_content_type()};base64,{b64}")
|
||||
attachments = [{"index": i, "name": _safe_name(p.get_filename() or f"anhang-{i + 1}"),
|
||||
"type": p.get_content_type(),
|
||||
"size": len(p.get_payload(decode=True) or b"")}
|
||||
for i, p in enumerate(msg.iter_attachments())]
|
||||
return {
|
||||
"uid": _uid_ok(uid), "folder": folder,
|
||||
"from": str(msg.get("From", "") or ""), "to": str(msg.get("To", "") or ""),
|
||||
"cc": str(msg.get("Cc", "") or ""), "subject": str(msg.get("Subject", "") or ""),
|
||||
"date": str(msg.get("Date", "") or ""), "text": text, "html": html,
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
|
||||
def get_attachment(folder: str, uid, index: int) -> tuple:
|
||||
msg = _fetch_message(folder, uid, mark_read=False)
|
||||
for i, part in enumerate(msg.iter_attachments()):
|
||||
if i == int(index):
|
||||
return (_safe_name(part.get_filename() or f"anhang-{i + 1}"),
|
||||
part.get_content_type(), part.get_payload(decode=True) or b"")
|
||||
raise MailError("Anhang nicht gefunden")
|
||||
|
||||
|
||||
# ── Ordner + Verschieben ────────────────────────────────────────────
|
||||
|
||||
def imap_utf7_encode(name: str) -> str:
|
||||
"""'Entwürfe' → 'Entw&APw-rfe' (Gegenstueck zu imap_utf7_decode)."""
|
||||
import base64
|
||||
out, buf = [], []
|
||||
|
||||
def flush():
|
||||
if buf:
|
||||
b = base64.b64encode("".join(buf).encode("utf-16-be")).decode("ascii")
|
||||
out.append("&" + b.rstrip("=").replace("/", ",") + "-")
|
||||
buf.clear()
|
||||
for ch in name:
|
||||
if 0x20 <= ord(ch) <= 0x7E:
|
||||
flush()
|
||||
out.append("&-" if ch == "&" else ch)
|
||||
else:
|
||||
buf.append(ch)
|
||||
flush()
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _delimiter(m) -> str:
|
||||
typ, data = m.list('""', '""')
|
||||
for line in data or []:
|
||||
s = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
|
||||
lm = _LIST_RE.match(s.strip())
|
||||
if lm and lm.group("delim") != "NIL":
|
||||
return lm.group("delim").strip('"')
|
||||
return "/"
|
||||
|
||||
|
||||
def _resolve_folder(folder: str) -> str:
|
||||
"""Ordner per Rohname ODER lesbarem Namen finden (ARIA sagt 'Entwürfe',
|
||||
der Server kennt 'Entw&APw-rfe'). Gross/klein egal."""
|
||||
want = (folder or "").strip()
|
||||
if not want:
|
||||
raise MailError("Kein Ordner angegeben")
|
||||
folders = list_folders()
|
||||
for f in folders:
|
||||
if f["name"] == want or f["label"] == want:
|
||||
return f["name"]
|
||||
for f in folders:
|
||||
if want.lower() in (f["name"].lower(), f["label"].lower()):
|
||||
return f["name"]
|
||||
raise MailError(f"Ordner {folder!r} gibt es nicht. Vorhanden: "
|
||||
+ ", ".join(f["label"] for f in folders))
|
||||
|
||||
|
||||
def create_folder(name: str, parent: str = "") -> dict:
|
||||
"""Ordner anlegen, optional als Unterordner. Der Name darf das
|
||||
Trennzeichen des Servers nicht enthalten — sonst entstuende still eine
|
||||
ganze Kette von Ordnern statt des einen gewuenschten."""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise MailError("Kein Ordnername")
|
||||
parent_raw = _resolve_folder(parent) if parent else ""
|
||||
m = _imap_open(load_config())
|
||||
try:
|
||||
delim = _delimiter(m)
|
||||
if delim and delim in name:
|
||||
raise MailError(f"Ordnername darf {delim!r} nicht enthalten — fuer Unterordner 'parent' angeben")
|
||||
raw = (parent_raw + delim if parent_raw else "") + imap_utf7_encode(name)
|
||||
typ, data = m.create(_quote(raw))
|
||||
if typ != "OK":
|
||||
msg = (data[0].decode("utf-8", "replace") if data and isinstance(data[0], bytes) else str(data))
|
||||
raise MailError(f"Anlegen abgelehnt: {msg[:200]}")
|
||||
m.subscribe(_quote(raw)) # sonst zeigen manche Mailprogramme ihn nicht
|
||||
return {"name": raw, "label": imap_utf7_decode(raw)}
|
||||
finally:
|
||||
_imap_close(m)
|
||||
|
||||
|
||||
def move_message(folder: str, uid, target: str) -> dict:
|
||||
"""Mail in einen anderen Ordner verschieben.
|
||||
|
||||
Mit MOVE (RFC 6851) in einem Schritt. Ohne: kopieren, als geloescht
|
||||
markieren und NUR diese Mail entfernen (UID EXPUNGE, braucht UIDPLUS). Ein
|
||||
nacktes EXPUNGE wuerde alles mitnehmen, was im Ordner sonst noch als
|
||||
geloescht markiert ist — dann bleibt die Mail lieber markiert liegen.
|
||||
"""
|
||||
u = _uid_ok(uid)
|
||||
src = _resolve_folder(folder or "INBOX")
|
||||
dst = _resolve_folder(target)
|
||||
if src == dst:
|
||||
raise MailError("Quelle und Ziel sind derselbe Ordner")
|
||||
m = _imap_open(load_config())
|
||||
try:
|
||||
_select(m, src, readonly=False)
|
||||
caps = {c.upper() for c in (m.capabilities or ())}
|
||||
if "MOVE" in caps:
|
||||
typ, data = m.uid("MOVE", u, _quote(dst))
|
||||
if typ != "OK":
|
||||
raise MailError(f"Verschieben abgelehnt: {data!r}"[:200])
|
||||
return {"ok": True, "from": imap_utf7_decode(src), "to": imap_utf7_decode(dst), "how": "move"}
|
||||
typ, data = m.uid("COPY", u, _quote(dst))
|
||||
if typ != "OK":
|
||||
raise MailError(f"Kopieren abgelehnt: {data!r}"[:200])
|
||||
m.uid("STORE", u, "+FLAGS", "(\\Deleted)")
|
||||
if "UIDPLUS" in caps:
|
||||
m.uid("EXPUNGE", u)
|
||||
how = "copy+expunge"
|
||||
else:
|
||||
how = "copy (Original als geloescht markiert)"
|
||||
return {"ok": True, "from": imap_utf7_decode(src), "to": imap_utf7_decode(dst), "how": how}
|
||||
finally:
|
||||
_imap_close(m)
|
||||
@@ -36,7 +36,8 @@ TICK_SEC = 8
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
|
||||
|
||||
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None:
|
||||
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list,
|
||||
project_id: str = "") -> None:
|
||||
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
|
||||
|
||||
Synchron via urllib — wird per run_in_executor aus dem async-Loop
|
||||
@@ -47,6 +48,10 @@ def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) ->
|
||||
"trigger_name": trigger_name,
|
||||
"type": ttype,
|
||||
"events": events or [],
|
||||
# Kontext, in dem der Trigger angelegt wurde. Leer = Hauptchat. Ohne das
|
||||
# landete JEDE Trigger-Antwort im Hauptchat, auch wenn der Timer mitten
|
||||
# in einem Projekt-Thread gestellt wurde.
|
||||
"projectId": project_id or "",
|
||||
}).encode("utf-8")
|
||||
url = f"{BRIDGE_URL}/internal/trigger-fired"
|
||||
try:
|
||||
@@ -156,17 +161,23 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
||||
# Executor auslagern, damit der Event-Loop frei bleibt.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Projekt, in dem der Trigger angelegt wurde — der Turn laeuft darin, und
|
||||
# die Antwort geht auch dorthin zurueck (statt immer in den Hauptchat).
|
||||
trigger_pid = str(trigger.get("project_id") or "")
|
||||
|
||||
def _run_turn():
|
||||
a = agent_factory()
|
||||
rep, *_rest = a.chat(prompt, source="trigger")
|
||||
rep, *_rest = a.chat(prompt, source="trigger", project_id=trigger_pid)
|
||||
return rep, a.pop_events()
|
||||
|
||||
reply, events = await loop.run_in_executor(None, _run_turn)
|
||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||
logger.info("[trigger] %s gefeuert (project=%s) → ARIA-Reply: %s",
|
||||
name, trigger_pid or "(main)", reply[:80])
|
||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
||||
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
||||
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype,
|
||||
events, trigger_pid)
|
||||
except Exception as e:
|
||||
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
||||
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
|
||||
|
||||
@@ -13,11 +13,27 @@ Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert
|
||||
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
|
||||
zieht die History das Modell weiter aus der Rolle.
|
||||
|
||||
Zweite Giftquelle: Anthropics Sicherheitsfilter
|
||||
----------------------------------------------
|
||||
Dasselbe Muster, anderer Auslöser. Der Filter ("... safeguards flagged this
|
||||
message", `Details: [cyber]`) antwortet nicht mit einem HTTP-Fehler: die CLI
|
||||
liefert ein normales Ergebnis mit is_error=true und dem Fehlertext im content.
|
||||
Vor dem safeguard.py-Fix wurde dieser Text als ARIAs Antwort persistiert — und
|
||||
die ausloesende Frage steht direkt davor. Beides geht bei jedem Folge-Turn
|
||||
wieder mit, also greift der Filter wieder: das Projekt ist tot.
|
||||
|
||||
Neue Vorfaelle faengt das Brain jetzt selbst ab (Modellwechsel, im Notfall
|
||||
Quarantaene). Was VORHER schon in der Datei steht, muss einmalig raus — dafuer
|
||||
ist dieses Script da. Diese Sorte wird in ALLEN Kontexten gesucht, nicht nur im
|
||||
Hauptthread: Projekt-Chats sind genau die, die daran sterben.
|
||||
|
||||
Was das Script tut
|
||||
------------------
|
||||
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
|
||||
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
|
||||
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
|
||||
- Findet in JEDEM Kontext Assistant-Turns, die der Fehlertext des
|
||||
Sicherheitsfilters sind.
|
||||
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
|
||||
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
|
||||
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
|
||||
@@ -61,10 +77,39 @@ _BREAK = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# Der Fehlertext des Sicherheitsfilters. Gleiche Marker wie safeguard.py; hier
|
||||
# dupliziert, damit das Script ohne Brain-Imports auf dem Host laeuft.
|
||||
# Verlangt wird ZWEIERLEI: der Text muss mit "API Error" beginnen UND einen
|
||||
# Filter-Marker enthalten. Sonst traefe es auch Turns, in denen ARIA ueber den
|
||||
# Filter *spricht* — und eine Erklaerung ist kein Gift.
|
||||
_FILTER_MARKERS = (
|
||||
"safeguards flagged this message",
|
||||
"anthropic.com/legal/aup",
|
||||
"can't respond to this message with",
|
||||
"cannot respond to this message with",
|
||||
"try rephrasing the request in a new session",
|
||||
)
|
||||
|
||||
|
||||
def is_poison(content: str) -> bool:
|
||||
return bool(_BREAK.search(content or ""))
|
||||
|
||||
|
||||
def is_filter_error(content: str) -> bool:
|
||||
t = (content or "").strip().lower()
|
||||
if not t.startswith("api error"):
|
||||
return False
|
||||
return any(m in t for m in _FILTER_MARKERS)
|
||||
|
||||
|
||||
def project_of(obj: dict) -> str:
|
||||
"""Projekt-Tag eines Turns. Brain nutzt 'project_id', UI/Bridge 'projectId'."""
|
||||
pid = obj.get("project_id")
|
||||
if pid is None:
|
||||
pid = obj.get("projectId")
|
||||
return str(pid or "").strip()
|
||||
|
||||
|
||||
def get_content(obj: dict) -> str:
|
||||
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
|
||||
v = obj.get("content")
|
||||
@@ -112,24 +157,38 @@ def main() -> int:
|
||||
continue
|
||||
if obj.get("op") == "distill":
|
||||
continue
|
||||
if obj.get("role") != "assistant" or not is_main_thread(obj):
|
||||
if obj.get("role") != "assistant":
|
||||
continue
|
||||
content = get_content(obj)
|
||||
if not content or not is_poison(content):
|
||||
if not content:
|
||||
continue
|
||||
# Zwei Giftsorten mit unterschiedlichem Geltungsbereich:
|
||||
# Rollen-Ablehnung — nur Hauptthread (so war es immer; in Projekten
|
||||
# waere das Risiko eines False Positive hoeher als der Nutzen).
|
||||
# Sicherheitsfilter — ueberall, denn genau Projekt-Chats sterben daran.
|
||||
if is_filter_error(content):
|
||||
reason = "Sicherheitsfilter"
|
||||
elif is_main_thread(obj) and is_poison(content):
|
||||
reason = "Rollen-Ablehnung"
|
||||
else:
|
||||
continue
|
||||
# Gift-Assistant-Turn -> droppen
|
||||
drop[i] = True
|
||||
user_idx = None
|
||||
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
|
||||
pid = project_of(obj)
|
||||
# Unmittelbar davor stehenden User-Turn DESSELBEN Kontexts (die
|
||||
# ausloesende Frage) mit weg — sonst bleibt der Ausloeser im Fenster.
|
||||
for j in range(i - 1, -1, -1):
|
||||
pj = parsed[j][1]
|
||||
if not isinstance(pj, dict) or pj.get("op") == "distill":
|
||||
continue
|
||||
if pj.get("role") == "user" and is_main_thread(pj):
|
||||
if project_of(pj) != pid:
|
||||
continue # fremder Kontext — ueberspringen, nicht abbrechen
|
||||
if pj.get("role") == "user":
|
||||
drop[j] = True
|
||||
user_idx = j
|
||||
break # nur der direkt vorangehende Turn
|
||||
poisoned_pairs.append((i, user_idx))
|
||||
break # nur der direkt vorangehende Turn dieses Kontexts
|
||||
poisoned_pairs.append((i, user_idx, reason, pid))
|
||||
|
||||
n_drop = sum(drop)
|
||||
if n_drop == 0:
|
||||
@@ -138,12 +197,13 @@ def main() -> int:
|
||||
|
||||
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
|
||||
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
|
||||
for a_idx, u_idx in poisoned_pairs:
|
||||
for a_idx, u_idx, reason, pid in poisoned_pairs:
|
||||
print(f" [{reason}] Kontext: {pid or 'Hauptchat'}")
|
||||
if u_idx is not None:
|
||||
uq = get_content(parsed[u_idx][1] or {})
|
||||
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
|
||||
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
|
||||
ac = get_content(parsed[a_idx][1] or {})
|
||||
print(f" Ablehng (Zeile {a_idx + 1}): {ac[:90]!r}")
|
||||
print(f" Antwort (Zeile {a_idx + 1}): {ac[:90]!r}")
|
||||
print()
|
||||
|
||||
if not apply:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Welches Modell gilt in welchem Chat.
|
||||
|
||||
Drei Ebenen, von unten nach oben:
|
||||
|
||||
1. Einstellung — `brainModel` aus runtime.json (Diagnostic → Sprachmodell).
|
||||
Gilt fuer alles, was keine eigene Wahl hat.
|
||||
2. Pro Chat — ein Override je Kontext ("" = Hauptchat, sonst project_id).
|
||||
Damit kann ein Projekt bewusst auf einem anderen Modell laufen, ohne das
|
||||
Hauptmodell fuer alles umzustellen.
|
||||
3. Ausweich — Anthropics Sicherheitsfilter hat das gewaehlte Modell an einem
|
||||
Turn blockiert, der noch im Fenster liegt (safeguard.py). Das sticht alles,
|
||||
ist aber nur so lange aktiv, wie der Turn im Fenster steht.
|
||||
|
||||
Warum ueberhaupt ein eigener Speicher und kein Feld im Projekt-Manifest: der
|
||||
Hauptchat IST kein Projekt. Ein Manifest-Feld haette ihn aussen vor gelassen,
|
||||
und genau dort faellt eine Modellwahl am ehesten an.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STORE_FILE = Path(os.environ.get("CONTEXT_MODELS_FILE",
|
||||
"/shared/config/context_models.json"))
|
||||
|
||||
# Begruendungen, wie sie die Oberflaechen anzeigen. Bewusst kurze Schluessel —
|
||||
# App und Diagnostic uebersetzen sie selbst, damit die Texte dort hingehoeren,
|
||||
# wo sie gelesen werden.
|
||||
REASON_SETTING = "einstellung"
|
||||
REASON_PER_CHAT = "pro-chat"
|
||||
REASON_FALLBACK = "ausweich"
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
if STORE_FILE.exists():
|
||||
data = json.loads(STORE_FILE.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict):
|
||||
# Nur String→String; alles andere waere ein Schreibfehler von
|
||||
# aussen und wuerde sonst still als Modellname durchgehen.
|
||||
return {str(k): str(v) for k, v in data.items() if isinstance(v, str)}
|
||||
except Exception as exc:
|
||||
logger.warning("[context-model] lesen fehlgeschlagen: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
try:
|
||||
STORE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STORE_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(STORE_FILE)
|
||||
except Exception as exc:
|
||||
logger.warning("[context-model] schreiben fehlgeschlagen: %s", exc)
|
||||
|
||||
|
||||
def all_overrides() -> dict:
|
||||
return _load()
|
||||
|
||||
|
||||
def get(context_id: str) -> str:
|
||||
"""Der Override dieses Kontexts, oder "" wenn keiner gesetzt ist."""
|
||||
return _load().get(context_id or "", "")
|
||||
|
||||
|
||||
def set(context_id: str, model: str) -> str:
|
||||
"""Override setzen. Leerer Modellname loescht ihn (= wieder Einstellung)."""
|
||||
cid = context_id or ""
|
||||
m = (model or "").strip()
|
||||
with _lock:
|
||||
data = _load()
|
||||
if m:
|
||||
data[cid] = m
|
||||
else:
|
||||
data.pop(cid, None)
|
||||
_save(data)
|
||||
logger.info("[context-model] %s → %s", cid or "(Hauptchat)", m or "(Einstellung)")
|
||||
return m
|
||||
|
||||
|
||||
def resolve(context_id: str, default_model: str) -> Tuple[str, str]:
|
||||
"""(Modell, Begruendung) ohne Sicherheitsfilter — den kennt nur der Agent."""
|
||||
ov = get(context_id)
|
||||
if ov:
|
||||
return ov, REASON_PER_CHAT
|
||||
return default_model, REASON_SETTING
|
||||
+262
-2
@@ -23,7 +23,7 @@ from typing import List, Optional
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File, Header
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -38,6 +38,9 @@ import watcher as watcher_mod
|
||||
import background as background_mod
|
||||
import oauth as oauth_mod
|
||||
import seed_rules as seed_rules_mod
|
||||
import safeguard as safeguard_mod
|
||||
import context_model as context_model_mod
|
||||
import aria_mail
|
||||
import projects as projects_mod
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
@@ -398,6 +401,19 @@ def memory_delete(point_id: str):
|
||||
|
||||
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
|
||||
|
||||
@app.get("/attachments/limit")
|
||||
def memory_attachments_limit():
|
||||
"""Aktuelles Groessen-Limit fuer Memory-Anhaenge.
|
||||
|
||||
Die Oberflaechen zeigen damit die ECHTE Zahl an statt eines fest
|
||||
eingetippten "max 20 MB" — das stand vorher im HTML und stimmte nicht mehr,
|
||||
sobald jemand das Limit anhob.
|
||||
"""
|
||||
import memory_attachments as mem_att
|
||||
limit = mem_att.max_bytes()
|
||||
return {"maxBytes": limit, "maxMb": round(limit / 1024 / 1024, 1)}
|
||||
|
||||
|
||||
@app.get("/memory/{point_id}/attachments")
|
||||
def memory_attachments_list(point_id: str):
|
||||
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
|
||||
@@ -666,6 +682,11 @@ class ChatOut(BaseModel):
|
||||
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
|
||||
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
|
||||
answered_by: str = "claude"
|
||||
# Welches Claude-Modell geantwortet hat (aus modelUsage der CLI, also die
|
||||
# aufgeloeste Version) und WARUM dieses: "einstellung" | "pro-chat" |
|
||||
# "ausweich" (Sicherheitsfilter). Leer, wenn kein Claude-Modell lief.
|
||||
model: str = ""
|
||||
model_reason: str = ""
|
||||
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
|
||||
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
|
||||
speak: bool = True
|
||||
@@ -764,7 +785,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||
loop = asyncio.get_running_loop()
|
||||
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
|
||||
reply, answered_by, speak, converse, awaiting_reply, model_info = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: a.chat(
|
||||
body.message, source=body.source, project_id=pid,
|
||||
@@ -787,6 +808,8 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
events=a.pop_events(),
|
||||
project_id=pid,
|
||||
answered_by=answered_by,
|
||||
model=(model_info or {}).get("model", ""),
|
||||
model_reason=(model_info or {}).get("reason", ""),
|
||||
speak=speak,
|
||||
converse=converse,
|
||||
awaiting_reply=awaiting_reply,
|
||||
@@ -1199,6 +1222,243 @@ def conversation_distill_now():
|
||||
return agent().distill_old_turns()
|
||||
|
||||
|
||||
# ─── Sicherheitsfilter (Anthropic) ──────────────────────────────────
|
||||
# Der Filter antwortet mit is_error=true und dem Fehlertext im content. Das
|
||||
# Brain erkennt das (safeguard.py), weicht aufs Ausweich-Modell aus und nimmt
|
||||
# im Notfall den ausloesenden Turn aus dem Prompt-Fenster. Hier nur die
|
||||
# Sichtbarkeit fuer die Diagnostic.
|
||||
|
||||
@app.get("/safeguard/status")
|
||||
def safeguard_status(limit: int = 25):
|
||||
"""Vorfaelle + Quarantaene. Fuer das Panel in der Diagnostic."""
|
||||
data = safeguard_mod.status(limit=limit)
|
||||
# Welche Turns noch im Fenster liegen (= wirken noch) — sonst sieht man in
|
||||
# der Liste nicht, ob eine Blockade noch aktiv ist oder nur Historie.
|
||||
conv = conversation()
|
||||
live = {t.ts for t in conv.turns}
|
||||
for inc in data.get("incidents", []):
|
||||
inc["in_window"] = bool(inc.get("turn_ts") and inc["turn_ts"] in live)
|
||||
for q in data.get("quarantine", []):
|
||||
q["in_window"] = bool(q.get("turn_ts") and q["turn_ts"] in live)
|
||||
# Den Text dazu zeigen, damit Stefan sieht, WAS blockiert wurde.
|
||||
q["content"] = next((t.content[:400] for t in conv.turns
|
||||
if t.ts == q.get("turn_ts")), "")
|
||||
return data
|
||||
|
||||
|
||||
class SafeguardTurnBody(BaseModel):
|
||||
turn_ts: str
|
||||
|
||||
|
||||
@app.post("/safeguard/unquarantine")
|
||||
def safeguard_unquarantine(body: SafeguardTurnBody):
|
||||
"""Einen quarantaenierten Turn wieder in den Verlauf aufnehmen.
|
||||
|
||||
Danach geht er wieder mit zum Modell — wenn er wirklich der Ausloeser war,
|
||||
blockt der Filter erneut. Deshalb einzeln und von Hand."""
|
||||
ok = safeguard_mod.unquarantine(body.turn_ts)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Kein quarantaenierter Turn mit diesem Zeitstempel")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class SafeguardClearBody(BaseModel):
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/safeguard/clear")
|
||||
def safeguard_clear(body: SafeguardClearBody):
|
||||
"""Vorfallsliste leeren. Hebt damit auch den Modell-Vermerk auf — der
|
||||
naechste Turn laeuft wieder aufs Hauptmodell. Quarantaene bleibt."""
|
||||
removed = safeguard_mod.clear(body.project_id)
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
# ─── Modell pro Chat ────────────────────────────────────────────────
|
||||
# Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter). Siehe
|
||||
# context_model.py. Diese Endpunkte liefern fuer jede Oberflaeche dieselbe
|
||||
# Sicht: was ist eingestellt, was laeuft WIRKLICH, und warum.
|
||||
|
||||
_MODEL_CHOICES_CACHE: dict = {"at": 0.0, "data": []}
|
||||
|
||||
|
||||
def _model_choices() -> list:
|
||||
"""Modell-Auswahl fuer die Pickers — dieselbe Liste wie in der Diagnostic
|
||||
(Proxy /v1/models, aus Anthropics Katalog). 10 Minuten im Speicher, denn
|
||||
die App fragt bei jedem Oeffnen des Pickers. Fail-soft: ohne Proxy eine
|
||||
leere Liste, die Anzeige des aktiven Modells funktioniert trotzdem."""
|
||||
import time as _t
|
||||
import httpx as _httpx
|
||||
now = _t.time()
|
||||
if _MODEL_CHOICES_CACHE["data"] and now - _MODEL_CHOICES_CACHE["at"] < 600:
|
||||
return _MODEL_CHOICES_CACHE["data"]
|
||||
try:
|
||||
base = os.environ.get("PROXY_URL", "http://proxy:3456").rstrip("/")
|
||||
r = _httpx.get(f"{base}/v1/models", timeout=8)
|
||||
data = [{
|
||||
"id": m.get("id", ""),
|
||||
"name": m.get("display_name") or m.get("id", ""),
|
||||
"section": m.get("section") or "tier",
|
||||
} for m in (r.json().get("data") or []) if m.get("id")]
|
||||
if data:
|
||||
_MODEL_CHOICES_CACHE.update(at=now, data=data)
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning("[context-model] Modell-Liste vom Proxy nicht ladbar: %s", exc)
|
||||
return _MODEL_CHOICES_CACHE["data"]
|
||||
|
||||
|
||||
def _context_model_rows() -> dict:
|
||||
a = agent()
|
||||
conv = conversation()
|
||||
# Das Modell, mit dem das Brain gerade wirklich laeuft — NICHT neu aus
|
||||
# runtime.json gelesen. brainModel greift erst nach Neustart; zeigten wir
|
||||
# hier den Dateiwert, saehe ein ausstehender Neustart aus wie erledigt.
|
||||
default = a.proxy.model
|
||||
contexts = [("", "Hauptchat")] + [
|
||||
(p["id"], p.get("name") or p["id"])
|
||||
for p in projects_mod.list_projects()
|
||||
]
|
||||
rows = []
|
||||
for cid, name in contexts:
|
||||
override = context_model_mod.get(cid)
|
||||
model, reason = context_model_mod.resolve(cid, default)
|
||||
window = safeguard_mod.filter_window(conv.window(project_id=cid))
|
||||
blocked = safeguard_mod.blocked_models(cid, [t.ts for t in window])
|
||||
fallback = ""
|
||||
if safeguard_mod.norm_model(model) in blocked:
|
||||
fallback = safeguard_mod.fallback_for(model)
|
||||
reason = context_model_mod.REASON_FALLBACK
|
||||
rows.append({
|
||||
"context_id": cid,
|
||||
"name": name,
|
||||
"override": override, # "" = folgt der Einstellung
|
||||
"selected": model, # was gewaehlt ist (Einstellung/pro Chat)
|
||||
"effective": fallback or model, # was der naechste Turn nutzt
|
||||
"reason": reason,
|
||||
"blocked": sorted(blocked),
|
||||
})
|
||||
choices = _model_choices()
|
||||
names = {c["id"]: c["name"] for c in choices}
|
||||
return {"default": default, "rows": rows, "choices": choices, "names": names}
|
||||
|
||||
|
||||
@app.get("/context-model/list")
|
||||
def context_model_list():
|
||||
return _context_model_rows()
|
||||
|
||||
|
||||
class ContextModelBody(BaseModel):
|
||||
context_id: str = ""
|
||||
model: str = "" # leer = Override loeschen, Einstellung gilt wieder
|
||||
|
||||
|
||||
@app.post("/context-model/set")
|
||||
def context_model_set(body: ContextModelBody):
|
||||
cid = (body.context_id or "").strip()
|
||||
if cid and not projects_mod.get_project(cid):
|
||||
raise HTTPException(404, f"Projekt {cid} nicht gefunden")
|
||||
context_model_mod.set(cid, body.model)
|
||||
return _context_model_rows()
|
||||
|
||||
|
||||
class ContextModelResetBody(BaseModel):
|
||||
context_id: str = ""
|
||||
|
||||
|
||||
@app.post("/context-model/back-to-selected")
|
||||
def context_model_back(body: ContextModelResetBody):
|
||||
"""Ausweich beenden: Sicherheitsfilter-Vermerke dieses Chats loeschen.
|
||||
|
||||
Danach laeuft der naechste Turn wieder auf dem gewaehlten Modell. Steht
|
||||
der ausloesende Text noch im Verlauf, kann der Filter erneut greifen —
|
||||
dann weicht das Brain eben wieder aus. Das ist gewollt: zurueck soll
|
||||
jederzeit gehen, ohne dass man vorher irgendwas aufraeumen muss."""
|
||||
removed = safeguard_mod.clear((body.context_id or "").strip())
|
||||
out = _context_model_rows()
|
||||
out["removed"] = removed
|
||||
return out
|
||||
|
||||
|
||||
# ─── E-Mail (aria_mail.py) ──────────────────────────────────────────
|
||||
# Konfiguration lesen/aendern NUR mit dem Admin-Schluessel (Header
|
||||
# X-Aria-Admin). Den reicht die Diagnostic durch; ARIAs Container kann ihn
|
||||
# nicht lesen (liegt in /data, das dort nicht eingehaengt ist). Ohne diesen
|
||||
# Schutz koennte eine eingehende Mail ARIA dazu bringen, per curl die
|
||||
# Whitelist zu oeffnen.
|
||||
# Das Postfach LESEN braucht keinen Schluessel — das kann ARIA ohnehin ueber
|
||||
# ihre Tools, und der Viewer nutzt dieselben Wege.
|
||||
|
||||
def _mail_admin(key: Optional[str]) -> None:
|
||||
if not aria_mail.check_admin(key):
|
||||
raise HTTPException(403, "Nur ueber die Diagnostic")
|
||||
|
||||
|
||||
def _mail_call(fn, *a, **kw):
|
||||
try:
|
||||
return fn(*a, **kw)
|
||||
except aria_mail.MailError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
|
||||
|
||||
# Schluessel beim Start anlegen. Die Diagnostic liest ihn aus der Datei; gaebe
|
||||
# es ihn erst beim ersten Admin-Zugriff, haette sie fuer genau diesen Zugriff
|
||||
# keinen.
|
||||
try:
|
||||
aria_mail.admin_key()
|
||||
except Exception as _exc: # /data fehlt (lokaler Test) — kein Startabbruch
|
||||
logger.warning("[mail] Admin-Schluessel nicht anlegbar: %s", _exc)
|
||||
|
||||
|
||||
@app.get("/email/status")
|
||||
def email_status():
|
||||
return aria_mail.status()
|
||||
|
||||
|
||||
@app.get("/email/config")
|
||||
def email_config_get(x_aria_admin: Optional[str] = Header(None)):
|
||||
_mail_admin(x_aria_admin)
|
||||
return aria_mail.public_config()
|
||||
|
||||
|
||||
@app.post("/email/config")
|
||||
def email_config_set(body: dict, x_aria_admin: Optional[str] = Header(None)):
|
||||
_mail_admin(x_aria_admin)
|
||||
return _mail_call(aria_mail.save_config, body or {})
|
||||
|
||||
|
||||
@app.post("/email/test")
|
||||
def email_test(x_aria_admin: Optional[str] = Header(None)):
|
||||
_mail_admin(x_aria_admin)
|
||||
return aria_mail.test_connection()
|
||||
|
||||
|
||||
@app.get("/email/folders")
|
||||
def email_folders():
|
||||
return {"folders": _mail_call(aria_mail.list_folders)}
|
||||
|
||||
|
||||
@app.get("/email/messages")
|
||||
def email_messages(folder: str = "INBOX", limit: int = 50, offset: int = 0,
|
||||
unread_only: bool = False, search: str = ""):
|
||||
return _mail_call(aria_mail.list_messages, folder, limit, offset, unread_only, search)
|
||||
|
||||
|
||||
@app.get("/email/message")
|
||||
def email_message(folder: str, uid: str):
|
||||
"""Fuer den Viewer — markiert NICHT als gelesen (sonst wuesste ARIA
|
||||
nicht mehr, was neu ist, nur weil Stefan reingeschaut hat)."""
|
||||
return _mail_call(aria_mail.view_message, folder, uid)
|
||||
|
||||
|
||||
@app.get("/email/attachment")
|
||||
def email_attachment(folder: str, uid: str, index: int):
|
||||
from urllib.parse import quote as _q
|
||||
name, ctype, data = _mail_call(aria_mail.get_attachment, folder, uid, index)
|
||||
return Response(content=data, media_type=ctype or "application/octet-stream",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{_q(name)}"})
|
||||
|
||||
|
||||
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
|
||||
|
||||
@app.get("/metrics/calls")
|
||||
|
||||
@@ -16,6 +16,7 @@ selbst hat in Stufe A kein Tool zum Upload.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -27,9 +28,41 @@ from typing import List, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
|
||||
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
|
||||
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
||||
|
||||
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
||||
# 64 MB: so viel holt die App auch auf ausdruecklichen Tipp noch herunter
|
||||
# (TAP_DL_MAX_BYTES in ChatScreen.tsx). Ein hoeheres Limit wuerde Anhaenge
|
||||
# erlauben, die das Handy anschliessend nicht mehr laden kann.
|
||||
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def max_bytes() -> int:
|
||||
"""Aktuelles Groessen-Limit fuer Anhaenge.
|
||||
|
||||
Reihenfolge: runtime.json (Diagnostic → System & Wartung) > ENV > Default.
|
||||
Wird bei JEDEM Aufruf gelesen, damit eine Aenderung in der Diagnostic sofort
|
||||
greift — ein Brain-Neustart nur fuers Anheben einer Zahl waere Unsinn.
|
||||
"""
|
||||
try:
|
||||
with RUNTIME_CONFIG_FILE.open() as f:
|
||||
mb = json.load(f).get("memoryAttachmentMaxMb")
|
||||
if mb is not None:
|
||||
val = int(float(mb) * 1024 * 1024)
|
||||
if val > 0:
|
||||
return val
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("runtime.json lesen fehlgeschlagen: %s", exc)
|
||||
env = os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES")
|
||||
if env:
|
||||
try:
|
||||
return int(env)
|
||||
except ValueError:
|
||||
logger.warning("MEMORY_ATTACHMENT_MAX_BYTES ist keine Zahl: %r", env)
|
||||
return _DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Macht aus einem User-Namen einen filesystem-sicheren String —
|
||||
@@ -78,8 +111,12 @@ def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
|
||||
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
|
||||
if not memory_id:
|
||||
raise ValueError("memory_id ist Pflicht")
|
||||
if len(data) > MAX_BYTES:
|
||||
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
|
||||
limit = max_bytes()
|
||||
if len(data) > limit:
|
||||
raise ValueError(
|
||||
f"Anhang zu gross ({len(data) // (1024 * 1024)} MB > "
|
||||
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
|
||||
f"unter System & Wartung.")
|
||||
safe = _safe_filename(filename)
|
||||
d = memory_dir(memory_id)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
@@ -165,8 +202,12 @@ def attach_from_path(memory_id: str, source_path: str) -> dict:
|
||||
if not src.is_file():
|
||||
raise ValueError(f"Datei nicht gefunden: {source_path}")
|
||||
size = src.stat().st_size
|
||||
if size > MAX_BYTES:
|
||||
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
|
||||
limit = max_bytes()
|
||||
if size > limit:
|
||||
raise ValueError(
|
||||
f"Datei zu gross ({size // (1024 * 1024)} MB > "
|
||||
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
|
||||
f"unter System & Wartung.")
|
||||
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
|
||||
data = src.read_bytes()
|
||||
return save_attachment(memory_id, src.name, data)
|
||||
|
||||
+111
-1
@@ -28,6 +28,7 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -129,7 +130,8 @@ 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:
|
||||
accept_header: Optional[str] = None,
|
||||
api_hosts: Optional[list[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.
|
||||
@@ -158,6 +160,12 @@ def register_provider(service: str, auth_url: str, token_url: str,
|
||||
entry["extra_auth_params"] = extra_auth_params
|
||||
if accept_header is not None:
|
||||
entry["accept_header"] = accept_header
|
||||
if api_hosts is not None:
|
||||
# Zusaetzliche API-Hosts, an die oauth_call den Token schicken darf.
|
||||
# Noetig, wenn die API auf einer anderen Domain liegt als der Login
|
||||
# (z.B. github.com -> api.github.com waere noch dieselbe Basis, aber
|
||||
# manche Provider nutzen voellig andere Namen).
|
||||
entry["api_hosts"] = [str(h).strip().lower() for h in api_hosts if str(h).strip()]
|
||||
apps[svc] = entry
|
||||
_save_json(APPS_FILE, apps)
|
||||
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
|
||||
@@ -416,6 +424,108 @@ def revoke(service: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def allowed_hosts(service: str) -> list[str]:
|
||||
"""Hosts, an die ein Token dieses Services geschickt werden darf.
|
||||
|
||||
SICHERHEITSKERN von `call()`: ohne diese Liste koennte ein Aufruf den
|
||||
Zugangstoken an einen beliebigen Server schicken — die Anfrage-URL kommt
|
||||
schliesslich vom Modell. Darum wird nur zugelassen, was zum Provider selbst
|
||||
gehoert: die Hosts aus auth_url/token_url plus optional eingetragene
|
||||
api_hosts. Subdomains desselben registrierbaren Namens zaehlen mit
|
||||
(accounts.spotify.com -> api.spotify.com), fremde Domains nie.
|
||||
"""
|
||||
cfg = _provider_config(service)
|
||||
hosts: set[str] = set()
|
||||
for key in ("auth_url", "token_url"):
|
||||
u = cfg.get(key) or ""
|
||||
try:
|
||||
h = urllib.parse.urlparse(u).hostname or ""
|
||||
except Exception:
|
||||
h = ""
|
||||
if h:
|
||||
hosts.add(h.lower())
|
||||
for h in (cfg.get("api_hosts") or []):
|
||||
h = str(h).strip().lower()
|
||||
if h:
|
||||
hosts.add(h)
|
||||
return sorted(hosts)
|
||||
|
||||
|
||||
def _host_allowed(host: str, allowed: list[str]) -> bool:
|
||||
"""True, wenn host zu einem der erlaubten Hosts gehoert.
|
||||
|
||||
Verglichen wird auf den letzten ZWEI Labels (spotify.com). Ein reiner
|
||||
endswith-Vergleich waere angreifbar: 'boesespotify.com' endet auch auf
|
||||
'spotify.com'.
|
||||
"""
|
||||
h = (host or "").lower().strip(".")
|
||||
if not h:
|
||||
return False
|
||||
|
||||
def base(x: str) -> str:
|
||||
parts = x.lower().strip(".").split(".")
|
||||
return ".".join(parts[-2:]) if len(parts) >= 2 else x.lower()
|
||||
|
||||
hb = base(h)
|
||||
return any(h == a or hb == base(a) for a in allowed)
|
||||
|
||||
|
||||
def call(service: str, url: str, method: str = "GET",
|
||||
body: Optional[str] = None, content_type: str = "application/json",
|
||||
timeout: float = 20.0, max_bytes: int = 200_000) -> tuple[int, str]:
|
||||
"""Fuehrt einen HTTP-Aufruf MIT dem Zugangstoken aus — ohne ihn herauszugeben.
|
||||
|
||||
Das ist der Ersatz fuer "gib mir den Token, ich rufe selbst": der Token
|
||||
verlaesst den Brain-Prozess nicht und kann damit auch nicht in einem
|
||||
Gespraechsprotokoll landen.
|
||||
|
||||
Returns (status_code, text). Wirft ValueError bei unerlaubter URL.
|
||||
"""
|
||||
svc = (service or "").strip()
|
||||
if not svc:
|
||||
raise ValueError("service ist Pflicht")
|
||||
u = (url or "").strip()
|
||||
if not u.startswith("https://"):
|
||||
# Kein http:// — der Token ginge im Klartext ueber die Leitung.
|
||||
raise ValueError("url muss mit https:// beginnen")
|
||||
host = urllib.parse.urlparse(u).hostname or ""
|
||||
allowed = allowed_hosts(svc)
|
||||
if not allowed:
|
||||
raise ValueError(
|
||||
f"Fuer '{svc}' sind keine Hosts hinterlegt — erst oauth_register_provider aufrufen.")
|
||||
if not _host_allowed(host, allowed):
|
||||
raise ValueError(
|
||||
f"Host '{host}' gehoert nicht zu '{svc}'. Erlaubt: {', '.join(allowed)}")
|
||||
|
||||
m = (method or "GET").upper()
|
||||
if m not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
raise ValueError(f"Methode nicht erlaubt: {method}")
|
||||
|
||||
record = get_token(svc) # refresht bei Bedarf
|
||||
token = record.get("access_token", "")
|
||||
ttype = record.get("token_type") or "Bearer"
|
||||
|
||||
data = body.encode("utf-8") if body else None
|
||||
req = urllib.request.Request(u, data=data, method=m)
|
||||
req.add_header("Authorization", f"{ttype} {token}")
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", content_type)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read(max_bytes + 1)
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(max_bytes + 1) if hasattr(exc, "read") else b""
|
||||
status = exc.code
|
||||
truncated = len(raw) > max_bytes
|
||||
text = raw[:max_bytes].decode("utf-8", "replace")
|
||||
if truncated:
|
||||
text += "\n…(gekuerzt)"
|
||||
logger.info("[oauth] call %s %s %s -> %s (%d Byte)", svc, m, host, status, len(raw))
|
||||
return status, text
|
||||
|
||||
|
||||
def list_services() -> list[dict]:
|
||||
"""Diagnostik: zeigt fuer jeden konfigurierten Service ob Token da ist
|
||||
+ Ablaufzeit. Wird von Diagnostic genutzt."""
|
||||
|
||||
+15
-1
@@ -457,8 +457,22 @@ def build_oauth_section(oauth_services: list[dict] | None,
|
||||
"",
|
||||
"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.",
|
||||
"1. `oauth_get_token(service)` fragen — sagt nur OB authentifiziert ist.",
|
||||
"2. Wirft 'Kein Token gespeichert'? → `oauth_authorize(service)` aufrufen, URL an Stefan, warten, dann nochmal `oauth_get_token`.",
|
||||
"3. API ansprechen mit `oauth_call(service, url, ...)` — das Brain haengt "
|
||||
"die Authorization selbst an.",
|
||||
"",
|
||||
"**Du bekommst den Zugangstoken NICHT zu sehen — und brauchst ihn nicht.** "
|
||||
"Das ist Absicht: alles was Du siehst, steht im Gespraechsprotokoll. Frag "
|
||||
"also nicht danach und versuche nicht, ihn aus Dateien oder Umgebungs"
|
||||
"variablen zu lesen. Fuer etwas, das oefter gebraucht wird, bau einen Skill "
|
||||
"— der holt sich den Token zur Laufzeit ueber "
|
||||
"`{BRAIN_INTERNAL_URL}/oauth/<service>/token`, serverseitig.",
|
||||
"",
|
||||
"**Secrets gehoeren nie in den Chat.** Wenn Stefan Dir ein Passwort, einen "
|
||||
"API-Key oder ein client_secret schicken will: NICHT hier abtippen lassen. "
|
||||
"Verweise ihn auf Diagnostic → OAuth-Apps bzw. die Skill-Konfiguration. "
|
||||
"Und wiederhole ein Secret niemals in Deiner Antwort.",
|
||||
"",
|
||||
f"**Callback-URL (fest, NICHT raten):** `{base}`",
|
||||
"Diese URL muss Stefan EINMAL pro Service im Provider-Dashboard als gueltige "
|
||||
|
||||
@@ -66,6 +66,13 @@ class ProxyResult(BaseModel):
|
||||
content: str = ""
|
||||
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
|
||||
finish_reason: str = ""
|
||||
# ARIA-Zusatzfelder des Proxys (cli-to-openai.js). Wichtig: ein Fehler der
|
||||
# CLI kommt NICHT als HTTP-Fehler, sondern als normale Antwort mit
|
||||
# is_error=true und dem Fehlertext im content. Ohne dieses Flag kann der
|
||||
# Agent einen Fehlertext nicht von einer Antwort unterscheiden — genau so
|
||||
# landete die Meldung des Sicherheitsfilters als ARIAs Antwort im Verlauf.
|
||||
is_error: bool = False
|
||||
model_used: str = "" # Modell, das tatsaechlich geantwortet hat
|
||||
|
||||
|
||||
class ProxyClient:
|
||||
@@ -177,7 +184,13 @@ class ProxyClient:
|
||||
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
|
||||
metrics.log_call(payload["model"], messages, content or "")
|
||||
|
||||
return ProxyResult(content=content or "", tool_calls=tool_calls, finish_reason=finish_reason)
|
||||
return ProxyResult(
|
||||
content=content or "",
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
is_error=bool(data.get("aria_error")),
|
||||
model_used=str(data.get("aria_model") or ""),
|
||||
)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Anthropics Sicherheitsfilter — erkennen, ausweichen, Verlauf heil halten.
|
||||
|
||||
Der Filter ("... safeguards flagged this message", `Details: [cyber]`) meldet
|
||||
sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig normales Ergebnis mit
|
||||
is_error=true, und der Fehlertext steht im `content` — also genau dort, wo
|
||||
sonst ARIAs Antwort steht. Ungeprueft passiert deshalb Folgendes:
|
||||
|
||||
1. Der Fehlertext landet per conversation.add("assistant", ...) im Verlauf.
|
||||
2. Jeder Folge-Turn schickt den Verlauf samt der ausloesenden Nachricht wieder
|
||||
mit — der Filter greift erneut.
|
||||
3. Das Projekt ist tot: jede weitere Frage scheitert an derselben Stelle.
|
||||
|
||||
Dagegen zwei Rettungsstufen:
|
||||
|
||||
Stufe 1 — Modellwechsel. Der Filter ist modellspezifisch ("Opus 5's
|
||||
safeguards ..."), Anthropic empfiehlt selbst "change your model". Derselbe
|
||||
Turn wird mit dem Ausweich-Modell neu gefragt. Der ausloesende Turn wird
|
||||
vermerkt, damit die naechsten Turns des Projekts nicht erst wieder gegen die
|
||||
Wand laufen. Der Vermerk verfaellt von selbst, sobald der Turn aus dem
|
||||
Rolling Window gerollt ist — nichts muss aufgeraeumt werden.
|
||||
|
||||
Stufe 2 — Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf
|
||||
selbst. Dann wird der ausloesende Turn aus dem Prompt-Fenster genommen.
|
||||
NICHT geloescht: er bleibt in conversation.jsonl, bleibt in der Diagnostic
|
||||
sichtbar und ist mit einem Klick zurueckholbar. Das Projekt ist damit im
|
||||
naechsten Turn wieder benutzbar.
|
||||
|
||||
Der Zustand liegt in /data/safeguard.json — bewusst NICHT in
|
||||
conversation.jsonl: ein Markieren wuerde dort ein Rewrite erzwingen, und das
|
||||
schreibt nur das In-Memory-Window zurueck (alles schon Destillierte waere weg).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_FILE = Path(os.environ.get("SAFEGUARD_STATE_FILE", "/data/safeguard.json"))
|
||||
RUNTIME_CONFIG_FILE = Path(os.environ.get("RUNTIME_CONFIG_FILE", "/shared/config/runtime.json"))
|
||||
|
||||
# Wie viele Vorfaelle wir aufbewahren. Reicht fuer die Diagnostic-Anzeige und
|
||||
# haelt die Datei klein.
|
||||
MAX_INCIDENTS = 200
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class Blocked(Exception):
|
||||
"""Auch das Ausweich-Modell wurde blockiert.
|
||||
|
||||
Dann hilft kein Modellwechsel mehr — es liegt am Verlauf selbst. Der Agent
|
||||
faengt das und geht auf Stufe 2 (Quarantaene).
|
||||
"""
|
||||
|
||||
def __init__(self, models, text: str = ""):
|
||||
self.models = [m for m in models if m]
|
||||
self.text = text or ""
|
||||
super().__init__("Sicherheitsfilter blockiert: " + ", ".join(self.models))
|
||||
|
||||
# Textmarker des Filters. Englisch und stabil — der Text kommt von Anthropic,
|
||||
# nicht von uns. Mehrere, weil die Formulierung sich aendern kann.
|
||||
_MARKERS = (
|
||||
"safeguards flagged this message",
|
||||
"anthropic.com/legal/aup",
|
||||
"can't respond to this message with",
|
||||
"cannot respond to this message with",
|
||||
"try rephrasing the request in a new session",
|
||||
)
|
||||
|
||||
_DETAIL_RE = re.compile(r"details:\s*`?\[([a-z0-9_-]+)\]`?", re.IGNORECASE)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def norm_model(model: str) -> str:
|
||||
"""Modellnamen vergleichbar machen (Gross/Klein, CLI-Prefix, Leerzeichen)."""
|
||||
m = (model or "").strip().lower()
|
||||
if m.startswith("claude-code-cli/"):
|
||||
m = m[len("claude-code-cli/"):]
|
||||
return m
|
||||
|
||||
|
||||
# ── Erkennung ───────────────────────────────────────────────────────
|
||||
|
||||
def looks_like_block(text: str, is_error: bool = False) -> bool:
|
||||
"""True, wenn dieser Antworttext der Sicherheitsfilter ist.
|
||||
|
||||
Zwei Wege, absichtlich unterschiedlich streng:
|
||||
|
||||
* is_error=true (die CLI sagt selbst "das war ein Fehler") — ein Marker
|
||||
genuegt.
|
||||
* Ohne is_error muss der Text mit "API Error" ANFANGEN. Sonst wuerde die
|
||||
Erkennung auch dann zuschlagen, wenn ARIA ueber den Filter *spricht*
|
||||
("der Fehler hiess: safeguards flagged ...") — eine Antwort ueber den
|
||||
Fehler ist kein Fehler.
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
low = t.lower()
|
||||
hits = sum(1 for m in _MARKERS if m in low)
|
||||
if hits == 0:
|
||||
return False
|
||||
if is_error:
|
||||
return True
|
||||
return low.startswith("api error") and len(t) <= 2000
|
||||
|
||||
|
||||
def detail_code(text: str) -> str:
|
||||
"""Der Klassifikator-Code aus "Details: `[cyber]`" — nur fuer die Anzeige."""
|
||||
m = _DETAIL_RE.search(text or "")
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
# ── Ausweich-Modell ─────────────────────────────────────────────────
|
||||
|
||||
def _runtime_cfg() -> dict:
|
||||
try:
|
||||
if RUNTIME_CONFIG_FILE.exists():
|
||||
return json.loads(RUNTIME_CONFIG_FILE.read_text(encoding="utf-8")) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("[safeguard] runtime.json lesen fehlgeschlagen: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def fallback_for(model: str) -> str:
|
||||
"""Welches Modell beim naechsten Versuch.
|
||||
|
||||
Vorrang hat `brainFallbackModel` aus runtime.json (Diagnostic). Zeigt das
|
||||
auf dasselbe Modell, ist es wertlos — dann faellt es auf die automatische
|
||||
Wahl zurueck: ein ANDERES Tier, denn der Filter haengt am Modell.
|
||||
|
||||
Pro Aufruf gelesen, nicht beim Import: eine Aenderung in der Diagnostic
|
||||
greift so ohne Brain-Neustart.
|
||||
"""
|
||||
want = str(_runtime_cfg().get("brainFallbackModel") or "").strip()
|
||||
primary = norm_model(model)
|
||||
if want and norm_model(want) != primary:
|
||||
return want
|
||||
if "sonnet" in primary:
|
||||
return "opus"
|
||||
# Opus, Fable, Haiku und alles Unbekannte → Sonnet. Sonnet ist das
|
||||
# breiteste Tier im Max-Abo und ein anderer Filter-Kontext als Opus.
|
||||
return "sonnet"
|
||||
|
||||
|
||||
# ── Zustand ─────────────────────────────────────────────────────────
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
if STATE_FILE.exists():
|
||||
data = json.loads(STATE_FILE.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("incidents", [])
|
||||
data.setdefault("quarantine", [])
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.warning("[safeguard] Zustand lesen fehlgeschlagen: %s", exc)
|
||||
return {"incidents": [], "quarantine": []}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
try:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(STATE_FILE)
|
||||
except Exception as exc:
|
||||
logger.warning("[safeguard] Zustand schreiben fehlgeschlagen: %s", exc)
|
||||
|
||||
|
||||
def record_block(project_id: str, turn_ts: str, model: str, fallback: str,
|
||||
text: str = "", recovered: bool = False) -> None:
|
||||
"""Einen Vorfall vermerken. turn_ts = der User-Turn, der mitlief."""
|
||||
with _lock:
|
||||
data = _load()
|
||||
data["incidents"].append({
|
||||
"at": _now(),
|
||||
"project_id": project_id or "",
|
||||
"turn_ts": turn_ts or "",
|
||||
"model": model or "",
|
||||
"fallback": fallback or "",
|
||||
"recovered": bool(recovered),
|
||||
"code": detail_code(text),
|
||||
"snippet": (text or "").strip()[:300],
|
||||
})
|
||||
data["incidents"] = data["incidents"][-MAX_INCIDENTS:]
|
||||
_save(data)
|
||||
|
||||
|
||||
def mark_recovered(turn_ts: str, model: str, fallback_used: str) -> None:
|
||||
"""Nachtragen, dass der Ausweichversuch geklappt hat (nur Anzeige)."""
|
||||
with _lock:
|
||||
data = _load()
|
||||
for inc in reversed(data["incidents"]):
|
||||
if inc.get("turn_ts") == turn_ts and norm_model(inc.get("model", "")) == norm_model(model):
|
||||
inc["recovered"] = True
|
||||
inc["fallback"] = fallback_used
|
||||
break
|
||||
_save(data)
|
||||
|
||||
|
||||
def blocked_models(project_id: str, turn_timestamps: Iterable[str]) -> set:
|
||||
"""Modelle, die an den Turns DIESES Fensters schon gescheitert sind.
|
||||
|
||||
Der Abgleich laeuft ueber die Turn-Zeitstempel: rollt der ausloesende Turn
|
||||
aus dem Fenster, taucht er hier nicht mehr auf und das Projekt darf wieder
|
||||
aufs Hauptmodell. Kein Aufraeumen, kein Ablaufdatum von Hand.
|
||||
"""
|
||||
want = set(turn_timestamps or ())
|
||||
if not want:
|
||||
return set()
|
||||
pid = project_id or ""
|
||||
out = set()
|
||||
for inc in _load()["incidents"]:
|
||||
if (inc.get("project_id") or "") != pid:
|
||||
continue
|
||||
ts = inc.get("turn_ts") or ""
|
||||
if ts and ts in want:
|
||||
m = norm_model(inc.get("model", ""))
|
||||
if m:
|
||||
out.add(m)
|
||||
return out
|
||||
|
||||
|
||||
# ── Quarantaene ─────────────────────────────────────────────────────
|
||||
|
||||
def quarantine(turn_ts: str, project_id: str = "", reason: str = "") -> None:
|
||||
if not turn_ts:
|
||||
return
|
||||
with _lock:
|
||||
data = _load()
|
||||
if any(q.get("turn_ts") == turn_ts for q in data["quarantine"]):
|
||||
return
|
||||
data["quarantine"].append({
|
||||
"turn_ts": turn_ts, "project_id": project_id or "",
|
||||
"at": _now(), "reason": reason or "",
|
||||
})
|
||||
_save(data)
|
||||
logger.warning("[safeguard] Turn %s in Quarantaene (Projekt %r)", turn_ts, project_id or "(main)")
|
||||
|
||||
|
||||
def unquarantine(turn_ts: str) -> bool:
|
||||
with _lock:
|
||||
data = _load()
|
||||
before = len(data["quarantine"])
|
||||
data["quarantine"] = [q for q in data["quarantine"] if q.get("turn_ts") != turn_ts]
|
||||
changed = len(data["quarantine"]) != before
|
||||
if changed:
|
||||
_save(data)
|
||||
if changed:
|
||||
logger.info("[safeguard] Quarantaene aufgehoben: %s", turn_ts)
|
||||
return changed
|
||||
|
||||
|
||||
def quarantined_ts() -> set:
|
||||
return {q.get("turn_ts") for q in _load()["quarantine"] if q.get("turn_ts")}
|
||||
|
||||
|
||||
def filter_window(turns: list) -> list:
|
||||
"""Quarantaenierte Turns aus dem Prompt-Fenster nehmen.
|
||||
|
||||
Greift NUR auf dem Weg zum Modell. Gespeichert bleibt alles — die
|
||||
Diagnostic und jeder Export sehen den Turn weiter.
|
||||
"""
|
||||
q = quarantined_ts()
|
||||
if not q:
|
||||
return turns
|
||||
return [t for t in turns if getattr(t, "ts", "") not in q]
|
||||
|
||||
|
||||
# ── Diagnostic ──────────────────────────────────────────────────────
|
||||
|
||||
def status(limit: int = 25) -> dict:
|
||||
data = _load()
|
||||
inc = data["incidents"][-limit:][::-1]
|
||||
return {
|
||||
"incidents": inc,
|
||||
"total": len(data["incidents"]),
|
||||
"quarantine": data["quarantine"],
|
||||
"fallback_model": str(_runtime_cfg().get("brainFallbackModel") or "").strip(),
|
||||
"state_file": str(STATE_FILE),
|
||||
}
|
||||
|
||||
|
||||
def clear(project_id: Optional[str] = None) -> int:
|
||||
"""Vorfaelle loeschen (Anzeige + Taint). Quarantaene bleibt — die wird
|
||||
einzeln aufgehoben, damit nicht ein Klick alle Blockaden zurueckholt."""
|
||||
with _lock:
|
||||
data = _load()
|
||||
before = len(data["incidents"])
|
||||
if project_id is None:
|
||||
data["incidents"] = []
|
||||
else:
|
||||
pid = project_id or ""
|
||||
data["incidents"] = [i for i in data["incidents"]
|
||||
if (i.get("project_id") or "") != pid]
|
||||
removed = before - len(data["incidents"])
|
||||
_save(data)
|
||||
return removed
|
||||
@@ -185,11 +185,21 @@ SEED_RULES: List[dict] = [
|
||||
"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."
|
||||
"client_secret direkt in den Skill-Code — und schreibe sie auch "
|
||||
"NIE in eine Chat-Antwort. Alles was im Gespraech steht, wird "
|
||||
"uebertragen und gespeichert.\n"
|
||||
"\n"
|
||||
"Fuer OAuth-Services (Spotify, Google, GitHub etc.):\n"
|
||||
"- Im Gespraech: `oauth_call('<service>', url, ...)` — das Brain "
|
||||
"haengt die Authorization an, Du siehst den Token nie.\n"
|
||||
"- Im Skill-Code: den Token zur Laufzeit holen ueber "
|
||||
"GET {BRAIN_INTERNAL_URL}/oauth/<service>/token. Der Aufruf macht "
|
||||
"Auto-Refresh; Stefan muss sich sonst alle 60 Minuten neu "
|
||||
"einloggen, das nervt.\n"
|
||||
"- Zugangsdaten, die kein OAuth sind (Benutzername/Passwort, "
|
||||
"API-Key): in die SKILL-KONFIGURATION (`skill_set_config`), nicht "
|
||||
"in den Code und nicht in den Chat. Der Skill bekommt sie zur "
|
||||
"Laufzeit als CFG_<NAME>-Umgebungsvariable."
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -235,9 +245,11 @@ SEED_RULES: List[dict] = [
|
||||
"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"
|
||||
"`oauth_get_token('<service>')`. Das refresht automatisch ueber den "
|
||||
"gespeicherten refresh_token und sagt Dir, ob wieder alles gueltig "
|
||||
"ist (den Token selbst bekommst Du bewusst nicht — "
|
||||
"`oauth_call` nutzt ihn fuer Dich). In 99% der Faelle reicht das: "
|
||||
"danach denselben Aufruf einfach nochmal.\n"
|
||||
"\n"
|
||||
"Nur wenn `oauth_get_token` selbst einen Fehler wirft "
|
||||
"('refresh failed', 'no refresh_token', 'service nicht "
|
||||
|
||||
+12
-2
@@ -146,6 +146,7 @@ def create_timer(
|
||||
fires_at_iso: str,
|
||||
message: str,
|
||||
author: str = "aria",
|
||||
project_id: str = "",
|
||||
) -> dict:
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
@@ -164,11 +165,16 @@ def create_timer(
|
||||
"created_at": _now_iso(),
|
||||
"fires_at": fires_at_iso,
|
||||
"message": message,
|
||||
# Projekt, in dem der Trigger angelegt wurde. Ohne das landete die
|
||||
# Antwort beim Feuern IMMER im Hauptchat — auch wenn der Timer mitten
|
||||
# in einem Projekt-Thread gestellt wurde. Leer = Hauptchat.
|
||||
"project_id": project_id or "",
|
||||
"fire_count": 0,
|
||||
"last_fired_at": None,
|
||||
}
|
||||
write(name, data)
|
||||
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
|
||||
logger.info("Trigger angelegt: %s (timer, fires_at=%s, project=%s)",
|
||||
name, fires_at_iso, project_id or "(main)")
|
||||
return data
|
||||
|
||||
|
||||
@@ -179,6 +185,7 @@ def create_watcher(
|
||||
check_interval_sec: int = 300,
|
||||
throttle_sec: int = 3600,
|
||||
author: str = "aria",
|
||||
project_id: str = "",
|
||||
) -> dict:
|
||||
_safe_name(name)
|
||||
if _path(name).exists():
|
||||
@@ -200,12 +207,15 @@ def create_watcher(
|
||||
"check_interval_sec": int(check_interval_sec),
|
||||
"throttle_sec": int(throttle_sec),
|
||||
"message": message,
|
||||
# Siehe create_timer: ohne Projekt landet die Antwort im Hauptchat.
|
||||
"project_id": project_id or "",
|
||||
"fire_count": 0,
|
||||
"last_fired_at": None,
|
||||
"last_checked_at": None,
|
||||
}
|
||||
write(name, data)
|
||||
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
|
||||
logger.info("Trigger angelegt: %s (watcher, cond='%s', project=%s)",
|
||||
name, condition, project_id or "(main)")
|
||||
return data
|
||||
|
||||
|
||||
|
||||
+512
-38
@@ -739,6 +739,14 @@ class ARIABridge:
|
||||
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
|
||||
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
|
||||
self._stt_stream_projects: dict[str, str] = {}
|
||||
# Zusatz-Absicht pro Stream, auch von stt_stream_start:
|
||||
# cmid — clientMsgId der Anhaenge, die zu DIESER Aufnahme gehoeren
|
||||
# (Sprachnachricht als Beschreibung zu einem Bild/Video).
|
||||
# interject — die Aufnahme ist ein Zwischenruf in den laufenden Turn,
|
||||
# keine neue Anfrage.
|
||||
# Getrennt von _stt_stream_projects gehalten: der Fokus-Default greift
|
||||
# auch fuer alte App-Versionen, die diese Felder nicht mitschicken.
|
||||
self._stt_stream_meta: dict[str, dict] = {}
|
||||
# Voice-Override aus letzter Chat-Nachricht einer App.
|
||||
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
||||
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
||||
@@ -757,6 +765,17 @@ class ARIABridge:
|
||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||
self._vnc_sessions: dict[str, dict] = {}
|
||||
# ── Satelliten-Port-Tunnel ───────────────────────────────────
|
||||
# ARIA sitzt hier, der Dienst steht in einem fremden LAN. Der Satellit
|
||||
# haelt dorthin die Verbindung; wir machen daraus einen GANZ NORMALEN
|
||||
# lokalen Port. Damit kann alles im Stack einfach draufzugreifen
|
||||
# (MCP-Client, curl, Browser) statt eine Sonder-API zu brauchen.
|
||||
# _tunnel_listeners: port -> {"server", "satellite", "target", "port"}
|
||||
# _tunnel_conns: tunnelId -> {"writer", "opened" (Future)}
|
||||
self._tunnel_listeners: dict[int, dict] = {}
|
||||
self._tunnel_conns: dict[str, dict] = {}
|
||||
self._TUNNEL_PORT_BASE = int(os.environ.get("TUNNEL_PORT_BASE", "19000"))
|
||||
self._TUNNEL_PORT_MAX = int(os.environ.get("TUNNEL_PORT_MAX", "19099"))
|
||||
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
|
||||
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
|
||||
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
|
||||
@@ -808,6 +827,18 @@ class ARIABridge:
|
||||
# Projekt landen statt im Hauptchat.
|
||||
self._pending_files_project_id: str = ""
|
||||
self._PENDING_FILES_WINDOW_SEC: float = 0.8
|
||||
# Wartet ein Upload auf eine SPRACHNACHRICHT als Beschreibung, reichen
|
||||
# 0.8s nicht: der Nutzer spricht erst, und Whisper braucht danach noch
|
||||
# ein paar Sekunden fuers Final-Transkript. Mit dem kurzen Fenster waere
|
||||
# der Anhang laengst alleine rausgegangen ("warte auf seine Anweisung"),
|
||||
# und der gesprochene Text kaeme als zweiter, unverbundener Turn.
|
||||
# Das lange Fenster ist nur eine Notbremse gegen Haengenbleiben, falls
|
||||
# gar kein Endpoint mehr kommt — der Normalfall ist der Merge im
|
||||
# stt_endpoint-Handler. Es MUSS ueber dem Aufnahme-Hard-Cap der App
|
||||
# liegen (Default 5 Minuten) plus Whisper-Final, sonst waeren die
|
||||
# Anhaenge bei einer langen Aufnahme vorher alleine rausgegangen.
|
||||
self._PENDING_FILES_VOICE_WINDOW_SEC: float = 420.0
|
||||
self._pending_files_await_voice: bool = False
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialisiert alle Komponenten.
|
||||
@@ -1562,14 +1593,22 @@ class ARIABridge:
|
||||
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
|
||||
display_text = strip_voice_tag_for_display(text)
|
||||
_answered_by = (payload.get("answeredBy") or "") if isinstance(payload, dict) else ""
|
||||
assistant_backup_ts = self._append_chat_backup({
|
||||
_model = (payload.get("model") or "") if isinstance(payload, dict) else ""
|
||||
_model_reason = (payload.get("modelReason") or "") if isinstance(payload, dict) else ""
|
||||
_backup_rec = {
|
||||
"role": "assistant",
|
||||
"text": display_text,
|
||||
"files": [{"serverPath": f["serverPath"], "name": f["name"],
|
||||
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
|
||||
"project_id": turn_pid,
|
||||
"answeredBy": _answered_by,
|
||||
})
|
||||
}
|
||||
# Nur wenn ein Claude-Modell lief — sonst bliebe ein leeres Feld in
|
||||
# jeder Fast-Path-Zeile stehen.
|
||||
if _model:
|
||||
_backup_rec["model"] = _model
|
||||
_backup_rec["modelReason"] = _model_reason
|
||||
assistant_backup_ts = self._append_chat_backup(_backup_rec)
|
||||
|
||||
metadata = payload.get("metadata", {})
|
||||
is_critical = metadata.get("critical", False)
|
||||
@@ -1620,6 +1659,10 @@ class ARIABridge:
|
||||
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "",
|
||||
# Quell-Backend (local/claude/fast-path) fuer den Diagnostic-Badge.
|
||||
"answeredBy": (payload.get("answeredBy") or "") if isinstance(payload, dict) else "",
|
||||
# Welches Claude-Modell + warum ("einstellung"/"pro-chat"/
|
||||
# "ausweich"). Leer bei Fast-Path/lokal.
|
||||
"model": _model,
|
||||
"modelReason": _model_reason,
|
||||
# Wird diese Antwort vorgelesen? Fast-Path/Steuerbefehl = False.
|
||||
# Die App braucht das: ohne TTS feuert onPlaybackFinished nie,
|
||||
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
|
||||
@@ -1879,6 +1922,13 @@ class ARIABridge:
|
||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||
|
||||
def _pending_files_window(self) -> float:
|
||||
"""Wie lange auf den zugehoerigen Text gewartet wird, bevor die Anhaenge
|
||||
alleine rausgehen. Lang, wenn eine Sprachnachricht unterwegs ist."""
|
||||
return (self._PENDING_FILES_VOICE_WINDOW_SEC
|
||||
if self._pending_files_await_voice
|
||||
else self._PENDING_FILES_WINDOW_SEC)
|
||||
|
||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||
@@ -1892,13 +1942,15 @@ class ARIABridge:
|
||||
text = self._build_pending_files_message("", files)
|
||||
self._pending_files = []
|
||||
self._pending_files_flush_task = None
|
||||
self._pending_files_await_voice = False
|
||||
pid = self._pending_files_project_id
|
||||
self._pending_files_project_id = ""
|
||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||
|
||||
async def _flush_pending_files_with_text(self, user_text: str,
|
||||
project_id: str = "",
|
||||
client_msg_id: str = "") -> bool:
|
||||
client_msg_id: str = "",
|
||||
require_match: bool = False) -> bool:
|
||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||
@@ -1913,12 +1965,20 @@ class ARIABridge:
|
||||
|
||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||
|
||||
require_match: nur mergen, wenn die cmid wirklich passt. Der Sprach-Pfad
|
||||
nutzt das — sonst wuerde eine Sprachnachricht, die zufaellig waehrend
|
||||
eines fremden Uploads fertig wird, dessen Anhaenge an sich reissen. Beim
|
||||
Text-Pfad bleibt der Fallback (alles mergen) wie bisher, damit nie ein
|
||||
Bild verloren geht.
|
||||
"""
|
||||
if not self._pending_files:
|
||||
return False
|
||||
cmid = (client_msg_id or "").strip()
|
||||
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||
if not matching:
|
||||
if require_match:
|
||||
return False
|
||||
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||
matching = list(self._pending_files)
|
||||
remaining: list = []
|
||||
@@ -1935,10 +1995,11 @@ class ARIABridge:
|
||||
self._pending_files_flush_task = None
|
||||
if remaining:
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
self._flush_pending_files_after(self._pending_files_window())
|
||||
)
|
||||
else:
|
||||
self._pending_files_project_id = ""
|
||||
self._pending_files_await_voice = False
|
||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||
# fertig ist (siehe chat-handler oben).
|
||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||
@@ -2030,6 +2091,10 @@ class ARIABridge:
|
||||
# Welcher Backend geantwortet hat (local/claude/fast-path) — fuer den
|
||||
# Quell-Badge in Diagnostic.
|
||||
answered_by = (data.get("answered_by") or "claude").strip()
|
||||
# Welches Claude-Modell geantwortet hat + warum (einstellung/pro-chat/
|
||||
# ausweich). Fuer die Anzeige in App und Diagnostic.
|
||||
answered_model = (data.get("model") or "").strip()
|
||||
model_reason = (data.get("model_reason") or "").strip()
|
||||
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
|
||||
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
|
||||
speak = data.get("speak", True)
|
||||
@@ -2132,6 +2197,8 @@ class ARIABridge:
|
||||
try:
|
||||
await self._process_core_response(reply, {"projectId": turn_project_id,
|
||||
"answeredBy": answered_by,
|
||||
"model": answered_model,
|
||||
"modelReason": model_reason,
|
||||
"speak": speak,
|
||||
"converse": converse,
|
||||
"awaiting_reply": awaiting_reply,
|
||||
@@ -2752,10 +2819,17 @@ class ARIABridge:
|
||||
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||
file_cmid = str(payload.get("clientMsgId") or "")
|
||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||
# awaitVoice: der Nutzer spricht gerade die Beschreibung zu diesem
|
||||
# Anhang ein. Dann NICHT nach 0.8s alleine rausschicken, sondern auf
|
||||
# den stt_endpoint warten (siehe _PENDING_FILES_VOICE_WINDOW_SEC).
|
||||
if payload.get("awaitVoice"):
|
||||
self._pending_files_await_voice = True
|
||||
logger.info("[rvs] Anhang wartet auf Sprachnachricht: %s (cmid=%s)",
|
||||
file_name, file_cmid[:12] or "?")
|
||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||
self._pending_files_flush_task.cancel()
|
||||
self._pending_files_flush_task = asyncio.create_task(
|
||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
||||
self._flush_pending_files_after(self._pending_files_window())
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -3324,8 +3398,18 @@ class ARIABridge:
|
||||
focused_pid = str(payload.get("projectId") or "")
|
||||
if req_id:
|
||||
self._stt_stream_projects[req_id] = focused_pid
|
||||
logger.info("[rvs] stt_stream_start id=%s focus=%s",
|
||||
req_id[:12], focused_pid or "(main)")
|
||||
# Absicht dieser Aufnahme: gehoert sie zu wartenden Anhaengen,
|
||||
# und ist sie ein Zwischenruf statt einer neuen Anfrage?
|
||||
att_cmid = str(payload.get("clientMsgId") or "")
|
||||
is_interject = bool(payload.get("interject"))
|
||||
if att_cmid or is_interject:
|
||||
self._stt_stream_meta[req_id] = {
|
||||
"cmid": att_cmid, "interject": is_interject,
|
||||
}
|
||||
logger.info("[rvs] stt_stream_start id=%s focus=%s%s%s",
|
||||
req_id[:12], focused_pid or "(main)",
|
||||
f" anhang-cmid={att_cmid[:12]}" if att_cmid else "",
|
||||
" [ZWISCHENRUF]" if is_interject else "")
|
||||
return
|
||||
|
||||
elif msg_type == "stt_stream_end":
|
||||
@@ -3340,10 +3424,10 @@ class ARIABridge:
|
||||
if req_id:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_later(
|
||||
20.0,
|
||||
lambda rid=req_id: self._stt_stream_projects.pop(rid, None),
|
||||
)
|
||||
def _drop(rid: str = req_id) -> None:
|
||||
self._stt_stream_projects.pop(rid, None)
|
||||
self._stt_stream_meta.pop(rid, None)
|
||||
loop.call_later(20.0, _drop)
|
||||
except Exception:
|
||||
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
|
||||
# dann lieber gar nicht aufraeumen als crashen.
|
||||
@@ -3407,11 +3491,18 @@ class ARIABridge:
|
||||
# weiter auseinander (man hoert den Wechsel erst).
|
||||
_norm_txt = " ".join(text.lower().split())
|
||||
_now_t = asyncio.get_event_loop().time()
|
||||
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
|
||||
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
|
||||
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
|
||||
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
|
||||
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
|
||||
# Idempotenz ueber audioRequestId weiter oben.
|
||||
_DUPE_WINDOW_SEC = 1.5
|
||||
if (_norm_txt
|
||||
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
|
||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
|
||||
text[:60])
|
||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
|
||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
|
||||
_DUPE_WINDOW_SEC, text[:60])
|
||||
return
|
||||
self._last_endpoint_norm = _norm_txt
|
||||
self._last_endpoint_at = _now_t
|
||||
@@ -3420,11 +3511,36 @@ class ARIABridge:
|
||||
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
||||
stream_req_id = payload.get("requestId", "") or ""
|
||||
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
|
||||
meta = self._stt_stream_meta.pop(stream_req_id, {})
|
||||
|
||||
# Zwischenruf per Sprache: in den LAUFENDEN Turn schieben, nicht als
|
||||
# neue Anfrage anstellen. Gleicher Weg wie der 📣-Knopf mit Text.
|
||||
if meta.get("interject"):
|
||||
logger.info("[rvs] stt_endpoint ist ein Zwischenruf → project=%s: %s",
|
||||
focused_pid or "(main)", text[:80])
|
||||
asyncio.create_task(
|
||||
self._interject_proxy_for_project(focused_pid, text))
|
||||
# Den erkannten Text trotzdem broadcasten, damit die
|
||||
# Sprach-Bubble in der App ihren Text bekommt statt ewig auf
|
||||
# "wird verarbeitet..." zu stehen.
|
||||
asyncio.create_task(self._send_to_rvs({
|
||||
"type": "chat",
|
||||
"payload": {
|
||||
"text": text,
|
||||
"sender": "stt",
|
||||
"audioRequestId": audio_request_id,
|
||||
"projectId": focused_pid,
|
||||
"interject": True,
|
||||
},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
}))
|
||||
return
|
||||
|
||||
asyncio.create_task(self._process_endpoint_text(
|
||||
text, interrupted, audio_request_id, location,
|
||||
client_msg_id=client_msg_id,
|
||||
focused_project_id=focused_pid))
|
||||
focused_project_id=focused_pid,
|
||||
attachment_client_msg_id=str(meta.get("cmid") or "")))
|
||||
return
|
||||
|
||||
elif msg_type == "oauth_callback":
|
||||
@@ -3512,6 +3628,42 @@ class ARIABridge:
|
||||
asyncio.create_task(self._check_desktop(session, port))
|
||||
return
|
||||
|
||||
elif msg_type == "sat_tunnel_opened":
|
||||
# Antwort des Satelliten auf unseren Verbindungswunsch.
|
||||
tid = (payload.get("tunnelId") or "").strip()
|
||||
conn = self._tunnel_conns.get(tid)
|
||||
fut = conn.get("opened") if conn else None
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result(payload)
|
||||
return
|
||||
|
||||
elif msg_type == "sat_tunnel_data":
|
||||
# LAN -> lokaler Socket. Der Satellit schickt dasselbe Feld in die
|
||||
# andere Richtung; unterschieden wird ueber die tunnelId, die nur
|
||||
# hier bekannt ist.
|
||||
tid = (payload.get("tunnelId") or "").strip()
|
||||
conn = self._tunnel_conns.get(tid)
|
||||
if conn:
|
||||
try:
|
||||
conn["writer"].write(base64.b64decode(payload.get("b64") or ""))
|
||||
except Exception as exc:
|
||||
logger.warning("[tunnel] schreiben (%s) fehlgeschlagen: %s", tid[:8], exc)
|
||||
return
|
||||
|
||||
elif msg_type == "sat_tunnel_closed":
|
||||
tid = (payload.get("tunnelId") or "").strip()
|
||||
conn = self._tunnel_conns.pop(tid, None)
|
||||
if conn:
|
||||
fut = conn.get("opened")
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result({"ok": False,
|
||||
"error": payload.get("reason") or "geschlossen"})
|
||||
try:
|
||||
conn["writer"].close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
elif msg_type == "vnc_open":
|
||||
session = str(payload.get("session") or "main")
|
||||
try:
|
||||
@@ -3543,6 +3695,10 @@ class ARIABridge:
|
||||
elif msg_type == "sat_hello":
|
||||
sid = (payload.get("id") or "").strip()
|
||||
if sid:
|
||||
# sat_hello kommt alle ~25s erneut. Nur Neuanmeldungen und echte
|
||||
# Aenderungen loggen — sonst ersaeuft das Bridge-Log in
|
||||
# "Satellit online", und die interessanten Zeilen sind weg.
|
||||
prev = self._satellites.get(sid)
|
||||
self._satellites[sid] = {
|
||||
"id": sid,
|
||||
"location": payload.get("location") or sid,
|
||||
@@ -3550,9 +3706,12 @@ class ARIABridge:
|
||||
"control": bool(payload.get("control")),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
||||
sid, self._satellites[sid]["location"],
|
||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
||||
cur = self._satellites[sid]
|
||||
if prev is None or any(prev.get(k) != cur.get(k)
|
||||
for k in ("location", "caps", "control")):
|
||||
logger.info("[sat] Satellit %s: %s (%s) caps=%s control=%s",
|
||||
"online" if prev is None else "aktualisiert",
|
||||
sid, cur["location"], cur["caps"], cur["control"])
|
||||
return
|
||||
|
||||
elif msg_type == "host_hello":
|
||||
@@ -3618,9 +3777,16 @@ class ARIABridge:
|
||||
"busy": bool(prev.get("busy", False)),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
|
||||
iid, self._workers[iid]["service"], self._workers[iid]["node"],
|
||||
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
|
||||
# Wie bei sat_hello: worker_hello wiederholt sich im Takt von
|
||||
# ~30s. Nur neue Boxen und echte Aenderungen (anderes Modell,
|
||||
# andere GPUs) ins Log — der Rest ist Rauschen.
|
||||
cur = self._workers[iid]
|
||||
if not prev or any(prev.get(k) != cur.get(k)
|
||||
for k in ("service", "node", "gpus", "model", "models")):
|
||||
logger.info("[worker] %s: %s (service=%s node=%s gpus=%s model=%s)",
|
||||
"online" if not prev else "aktualisiert",
|
||||
iid, cur["service"], cur["node"],
|
||||
cur["gpus"] or "?", cur["model"] or "?")
|
||||
return
|
||||
|
||||
elif msg_type == "stt_lease_request":
|
||||
@@ -3777,8 +3943,12 @@ class ARIABridge:
|
||||
r")\s*[.!?]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
|
||||
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
|
||||
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
|
||||
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
|
||||
_VOICE_META_PROJECT_PREFIX = re.compile(
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
|
||||
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -3833,7 +4003,10 @@ class ARIABridge:
|
||||
s = SequenceMatcher(None, q, pname).ratio()
|
||||
if s > best_score:
|
||||
best, best_score = p, s
|
||||
if best and best_score >= 0.6:
|
||||
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
|
||||
# Aehnlichkeit, und die Nachricht verschwand still in einem
|
||||
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
|
||||
if best and best_score >= 0.82:
|
||||
pid = best["id"]
|
||||
self._voice_sticky_project_id = pid
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
@@ -3845,8 +4018,10 @@ class ARIABridge:
|
||||
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||
# Sticky refreshen
|
||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
|
||||
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
|
||||
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
|
||||
# Zeit aus und der Hauptchat uebernimmt wieder.
|
||||
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||
# Sticky abgelaufen — zurücksetzen
|
||||
self._voice_sticky_project_id = ""
|
||||
@@ -3861,7 +4036,8 @@ class ARIABridge:
|
||||
audio_request_id: str = "",
|
||||
location: Optional[dict] = None,
|
||||
client_msg_id: Optional[str] = None,
|
||||
focused_project_id: str = "") -> None:
|
||||
focused_project_id: str = "",
|
||||
attachment_client_msg_id: str = "") -> None:
|
||||
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
||||
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
||||
|
||||
@@ -3930,6 +4106,18 @@ class ARIABridge:
|
||||
except Exception as e:
|
||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||
|
||||
# Sprachnachricht als Beschreibung zu Anhaengen: die liegen schon
|
||||
# gepuffert (awaitVoice) und warten genau auf diesen Text — beides geht
|
||||
# als EIN Turn ans Brain, sonst sieht ARIA ein Bild ohne Auftrag und
|
||||
# danach einen Satz ohne Bild.
|
||||
if attachment_client_msg_id and await self._flush_pending_files_with_text(
|
||||
cleaned, project_id=project_id,
|
||||
client_msg_id=attachment_client_msg_id,
|
||||
require_match=True):
|
||||
logger.info("[rvs] Sprachnachricht mit ihren Anhaengen gemerged (cmid=%s)",
|
||||
attachment_client_msg_id[:12])
|
||||
return
|
||||
|
||||
core_text = self._build_core_text(cleaned, interrupted, location)
|
||||
await self.send_to_core(core_text,
|
||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||
@@ -4495,11 +4683,15 @@ class ARIABridge:
|
||||
trigger_name = data.get("trigger_name", "")
|
||||
ttype = data.get("type", "trigger")
|
||||
events = data.get("events") or []
|
||||
logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, events=%d",
|
||||
trigger_name, ttype, len(reply), len(events))
|
||||
trigger_pid = str(data.get("projectId") or "")
|
||||
logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, "
|
||||
"events=%d, project=%s",
|
||||
trigger_name, ttype, len(reply), len(events),
|
||||
trigger_pid or "(main)")
|
||||
# Async-spawn — HTTP-Antwort nicht durch RVS-Broadcast blockieren
|
||||
asyncio.create_task(
|
||||
self._handle_trigger_fired(reply, trigger_name, ttype, events)
|
||||
self._handle_trigger_fired(reply, trigger_name, ttype, events,
|
||||
trigger_pid)
|
||||
)
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/agent-activity":
|
||||
@@ -4573,6 +4765,57 @@ class ARIABridge:
|
||||
elif method in ("GET", "POST") and path == "/internal/worker-list":
|
||||
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
|
||||
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
|
||||
elif method == "POST" and path == "/internal/tunnel":
|
||||
# Brain-Tool: Port aus einem Satelliten-LAN lokal verfuegbar
|
||||
# machen. body: {op: open|close|list, satellite, target,
|
||||
# port, localPort?}
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
op = (data.get("op") or "open").strip()
|
||||
if op == "list":
|
||||
await _send_response(writer, 200, {"ok": True, "tunnels": self.tunnel_list()})
|
||||
elif op == "close":
|
||||
res = await self.tunnel_stop(int(data.get("localPort") or 0))
|
||||
await _send_response(writer, 200, res)
|
||||
else:
|
||||
res = await self.tunnel_listen(
|
||||
(data.get("satellite") or "").strip(),
|
||||
(data.get("target") or "").strip(),
|
||||
int(data.get("port") or 0),
|
||||
int(data.get("localPort") or 0))
|
||||
await _send_response(writer, 200, res)
|
||||
elif method == "POST" and path == "/internal/display-name":
|
||||
# Anzeigename fuer einen Host-Agenten oder Satelliten.
|
||||
# body: {kind: host|satellite, id, name}
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
res = self._display_name_set((data.get("kind") or "").strip(),
|
||||
(data.get("id") or "").strip(),
|
||||
data.get("name") or "")
|
||||
await _send_response(writer, 200, res)
|
||||
elif method == "POST" and path == "/internal/satellite-name":
|
||||
# Brain/Diagnostic: Geraetenamen vergeben. body: {satellite, mac|ip, name}
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||
return
|
||||
await self._send_to_rvs({
|
||||
"type": "sat_name_set",
|
||||
"payload": {"satellite": data.get("satellite") or "",
|
||||
"mac": data.get("mac") or "",
|
||||
"ip": data.get("ip") or "",
|
||||
"name": data.get("name") or "",
|
||||
"requestId": uuid.uuid4().hex},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
await _send_response(writer, 200, {"ok": True})
|
||||
elif method == "POST" and path == "/internal/host-list":
|
||||
# Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities.
|
||||
await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()})
|
||||
@@ -4776,6 +5019,107 @@ class ARIABridge:
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
# ── Satelliten-Port-Tunnel ──────────────────────────────────────
|
||||
|
||||
async def tunnel_listen(self, satellite: str, target: str, port: int,
|
||||
local_port: int = 0) -> dict:
|
||||
"""Macht aus <satellite>/<target>:<port> einen lokalen Port.
|
||||
|
||||
Jede eingehende Verbindung auf dem lokalen Port wird einzeln durch den
|
||||
Satelliten zum Ziel gefuehrt — mehrere parallele Verbindungen sind also
|
||||
kein Problem (ein Browser oeffnet gern sechs).
|
||||
"""
|
||||
if local_port and not (self._TUNNEL_PORT_BASE <= local_port <= self._TUNNEL_PORT_MAX):
|
||||
return {"ok": False, "error":
|
||||
f"localPort muss zwischen {self._TUNNEL_PORT_BASE} und "
|
||||
f"{self._TUNNEL_PORT_MAX} liegen."}
|
||||
if not local_port:
|
||||
for p in range(self._TUNNEL_PORT_BASE, self._TUNNEL_PORT_MAX + 1):
|
||||
if p not in self._tunnel_listeners:
|
||||
local_port = p
|
||||
break
|
||||
if not local_port:
|
||||
return {"ok": False, "error": "Keine freien Tunnel-Ports mehr."}
|
||||
if local_port in self._tunnel_listeners:
|
||||
await self.tunnel_stop(local_port)
|
||||
|
||||
async def on_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
tid = uuid.uuid4().hex
|
||||
opened: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
self._tunnel_conns[tid] = {"writer": writer, "opened": opened}
|
||||
await self._send_to_rvs({
|
||||
"type": "sat_tunnel_open",
|
||||
"payload": {"satellite": satellite, "tunnelId": tid,
|
||||
"target": target, "port": port},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
try:
|
||||
res = await asyncio.wait_for(opened, timeout=20)
|
||||
except asyncio.TimeoutError:
|
||||
res = {"ok": False, "error": "Satellit antwortet nicht"}
|
||||
if not res.get("ok"):
|
||||
logger.warning("[tunnel] %s:%d nicht erreichbar: %s",
|
||||
target, port, res.get("error"))
|
||||
self._tunnel_conns.pop(tid, None)
|
||||
try:
|
||||
writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(32768)
|
||||
if not data:
|
||||
break
|
||||
await self._send_to_rvs({
|
||||
"type": "sat_tunnel_data",
|
||||
"payload": {"tunnelId": tid,
|
||||
"b64": base64.b64encode(data).decode("ascii")},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._tunnel_conns.pop(tid, None)
|
||||
await self._send_to_rvs({
|
||||
"type": "sat_tunnel_close",
|
||||
"payload": {"satellite": satellite, "tunnelId": tid},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
try:
|
||||
writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
server = await asyncio.start_server(on_client, "0.0.0.0", local_port)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Port {local_port} nicht belegbar: {exc}"}
|
||||
self._tunnel_listeners[local_port] = {
|
||||
"server": server, "satellite": satellite, "target": target, "port": port}
|
||||
logger.info("[tunnel] lokal :%d -> %s/%s:%d", local_port, satellite, target, port)
|
||||
# Adresse aus Sicht der ANDEREN Container (Brain/Proxy liegen im selben
|
||||
# Docker-Netz) — damit ARIA weiss, was sie ansprechen soll.
|
||||
return {"ok": True, "localPort": local_port,
|
||||
"address": f"aria-bridge:{local_port}",
|
||||
"satellite": satellite, "target": target, "port": port}
|
||||
|
||||
async def tunnel_stop(self, local_port: int) -> dict:
|
||||
entry = self._tunnel_listeners.pop(int(local_port), None)
|
||||
if not entry:
|
||||
return {"ok": False, "error": f"Auf Port {local_port} laeuft kein Tunnel."}
|
||||
try:
|
||||
entry["server"].close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("[tunnel] lokal :%s geschlossen", local_port)
|
||||
return {"ok": True, "localPort": int(local_port)}
|
||||
|
||||
def tunnel_list(self) -> list[dict]:
|
||||
return [{"localPort": p, "address": f"aria-bridge:{p}",
|
||||
"satellite": e["satellite"], "target": e["target"], "port": e["port"]}
|
||||
for p, e in sorted(self._tunnel_listeners.items())]
|
||||
|
||||
async def _vnc_open(self, session: str, port: int) -> None:
|
||||
"""Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als
|
||||
vnc_data ueber RVS. Bestehende Session wird vorher geschlossen."""
|
||||
@@ -4842,15 +5186,37 @@ class ARIABridge:
|
||||
def _satellite_list(self) -> list[dict]:
|
||||
"""Bekannte Satelliten (frisch = in den letzten 5 Min gesehen)."""
|
||||
now = time.time()
|
||||
aliases = self._display_names("satellite")
|
||||
out = []
|
||||
for s in self._satellites.values():
|
||||
own = s.get("location") or s["id"]
|
||||
alias = aliases.get(s["id"], "")
|
||||
out.append({
|
||||
"id": s["id"], "location": s.get("location") or s["id"],
|
||||
"id": s["id"], "location": alias or own,
|
||||
"agentName": own, "alias": alias,
|
||||
"caps": s.get("caps") or [], "control": bool(s.get("control")),
|
||||
"online": (now - s.get("last_seen", 0)) < 300,
|
||||
})
|
||||
return out
|
||||
|
||||
def _resolve_satellite(self, who: str) -> str:
|
||||
"""Anzeigename ODER echte Kennung -> echte Kennung.
|
||||
|
||||
Gleicher Grund wie bei _resolve_host: der Satellit vergleicht die
|
||||
Adresse gegen seine eigene ID/Location und kennt den Alias nicht.
|
||||
"""
|
||||
w = (who or "").strip()
|
||||
if not w or w in self._satellites:
|
||||
return w
|
||||
wl = w.lower()
|
||||
for sid, alias in self._display_names("satellite").items():
|
||||
if alias.strip().lower() == wl:
|
||||
return sid
|
||||
for sid, s in self._satellites.items():
|
||||
if (s.get("location") or "").strip().lower() == wl:
|
||||
return sid
|
||||
return w
|
||||
|
||||
# worker_ping kommt alle ~10s; nach 35s ohne Ping gilt ein Worker als offline.
|
||||
WORKER_OFFLINE_S = 35
|
||||
|
||||
@@ -4914,6 +5280,8 @@ class ARIABridge:
|
||||
Muster identisch zu _flux_generate (requestId → Future)."""
|
||||
if self.ws_rvs is None:
|
||||
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||
# Anzeigename -> echte Kennung, sonst erkennt sich der Satellit nicht.
|
||||
satellite = self._resolve_satellite(satellite)
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
@@ -4941,15 +5309,113 @@ class ARIABridge:
|
||||
finally:
|
||||
self._pending_sat.pop(request_id, None)
|
||||
|
||||
# ── Anzeigenamen fuer Host-Agenten ───────────────────────────────
|
||||
#
|
||||
# Der Name eines Agenten kommt von ihm selbst: beim Desktop-Agenten aus
|
||||
# HOST_NAME in seiner .env, beim Android-Agenten aus der App-Konfiguration.
|
||||
# Umbenennen hiesse also: auf den Rechner, Datei aendern, neu starten — bzw.
|
||||
# beim Fire TV neu provisionieren.
|
||||
#
|
||||
# Darum hier ein Alias: hostId -> Anzeigename, zentral in der Bridge. Wirkt
|
||||
# sofort und fuer JEDEN schon ausgerollten Agenten, ohne ihn anzufassen.
|
||||
#
|
||||
# WICHTIG: der Alias wird beim Adressieren wieder aufgeloest (siehe
|
||||
# _resolve_host). Sonst koennte man einen Agenten zwar umbenennen, ihn unter
|
||||
# dem neuen Namen aber nicht mehr ansprechen — er vergleicht intern gegen
|
||||
# seine EIGENE Kennung.
|
||||
# Eine Datei fuer beides: {"host": {id: name}, "satellite": {id: name}}
|
||||
_DISPLAY_NAMES_FILE = "/shared/config/display_names.json"
|
||||
|
||||
def _display_names(self, kind: str) -> dict:
|
||||
try:
|
||||
with open(self._DISPLAY_NAMES_FILE) as f:
|
||||
data = json.load(f)
|
||||
sub = (data or {}).get(kind)
|
||||
return sub if isinstance(sub, dict) else {}
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception as exc:
|
||||
logger.warning("[names] display_names.json unlesbar: %s", exc)
|
||||
return {}
|
||||
|
||||
def _display_name_set(self, kind: str, ident: str, name: str) -> dict:
|
||||
ident = (ident or "").strip()
|
||||
if kind not in ("host", "satellite"):
|
||||
return {"ok": False, "error": "kind muss host oder satellite sein"}
|
||||
if not ident:
|
||||
return {"ok": False, "error": "id ist Pflicht"}
|
||||
try:
|
||||
with open(self._DISPLAY_NAMES_FILE) as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
data = {}
|
||||
sub = data.get(kind) if isinstance(data.get(kind), dict) else {}
|
||||
if (name or "").strip():
|
||||
sub[ident] = (name or "").strip()[:60]
|
||||
else:
|
||||
sub.pop(ident, None)
|
||||
data[kind] = sub
|
||||
try:
|
||||
# Verzeichnis aus dem Dateipfad ableiten statt fest zu verdrahten —
|
||||
# sonst laesst sich _DISPLAY_NAMES_FILE nicht umbiegen (Test, anderes
|
||||
# Deployment) und das Schreiben scheitert stillschweigend.
|
||||
os.makedirs(os.path.dirname(self._DISPLAY_NAMES_FILE) or ".", exist_ok=True)
|
||||
tmp = self._DISPLAY_NAMES_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self._DISPLAY_NAMES_FILE)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logger.info("[names] %s %s -> %r", kind, ident, sub.get(ident) or "(entfernt)")
|
||||
return {"ok": True, "kind": kind, "id": ident, "name": sub.get(ident, "")}
|
||||
|
||||
def _host_names(self) -> dict:
|
||||
return self._display_names("host")
|
||||
|
||||
def _resolve_host(self, who: str) -> str:
|
||||
"""Anzeigename ODER echte Kennung -> echte Kennung.
|
||||
|
||||
Ohne das waere ein umbenannter Agent nicht mehr ansprechbar: er
|
||||
vergleicht die Adresse gegen seine eigene HOST_ID/HOST_NAME und kennt
|
||||
den Alias nicht.
|
||||
"""
|
||||
w = (who or "").strip()
|
||||
if not w:
|
||||
return w
|
||||
if w in self._hosts:
|
||||
return w
|
||||
wl = w.lower()
|
||||
for hid, alias in self._host_names().items():
|
||||
if alias.strip().lower() == wl:
|
||||
return hid
|
||||
for hid, h in self._hosts.items():
|
||||
if (h.get("name") or "").strip().lower() == wl:
|
||||
return hid
|
||||
return w
|
||||
|
||||
def _host_list(self) -> list[dict]:
|
||||
"""Bekannte Host-Agenten (frisch = in den letzten 5 Min gesehen)."""
|
||||
now = time.time()
|
||||
return [{
|
||||
"hostId": h["hostId"], "name": h.get("name") or h["hostId"],
|
||||
"os": h.get("os") or "", "caps": h.get("caps") or [],
|
||||
"control": bool(h.get("control")),
|
||||
"online": (now - h.get("last_seen", 0)) < 300,
|
||||
} for h in self._hosts.values()]
|
||||
aliases = self._host_names()
|
||||
out = []
|
||||
for h in self._hosts.values():
|
||||
hid = h["hostId"]
|
||||
own = h.get("name") or hid
|
||||
alias = aliases.get(hid, "")
|
||||
out.append({
|
||||
"hostId": hid,
|
||||
"name": alias or own,
|
||||
# Eigener Name des Agenten — damit in der Oberflaeche sichtbar
|
||||
# bleibt, dass da ein Alias drueberliegt.
|
||||
"agentName": own,
|
||||
"alias": alias,
|
||||
"os": h.get("os") or "", "caps": h.get("caps") or [],
|
||||
"control": bool(h.get("control")),
|
||||
"online": (now - h.get("last_seen", 0)) < 300,
|
||||
})
|
||||
return out
|
||||
|
||||
_AGENT_HISTORY_DIR = "/shared/config/agent_history"
|
||||
_AGENT_HISTORY_MAX = 200
|
||||
@@ -5003,6 +5469,8 @@ class ARIABridge:
|
||||
host_result. Muster identisch zu _satellite_request."""
|
||||
if self.ws_rvs is None:
|
||||
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
|
||||
# Anzeigename -> echte Kennung, sonst erkennt sich der Agent nicht.
|
||||
host = self._resolve_host(host)
|
||||
request_id = str(uuid.uuid4())
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
@@ -5110,7 +5578,8 @@ class ARIABridge:
|
||||
return {"ok": True, "role": role, "content_preview": content[:80]}
|
||||
|
||||
async def _handle_trigger_fired(self, reply: str, trigger_name: str,
|
||||
ttype: str, events: list) -> None:
|
||||
ttype: str, events: list,
|
||||
project_id: str = "") -> None:
|
||||
"""Spiegelt eine Brain-Trigger-Antwort wie eine normale ARIA-Antwort.
|
||||
|
||||
Side-Channel-Events zuerst (trigger_created, location_tracking, ...),
|
||||
@@ -5162,7 +5631,12 @@ class ARIABridge:
|
||||
try:
|
||||
await self._process_core_response(
|
||||
reply,
|
||||
{"metadata": {"trigger_name": trigger_name, "trigger_type": ttype}},
|
||||
{"metadata": {"trigger_name": trigger_name, "trigger_type": ttype},
|
||||
# Ohne projectId landete jede Trigger-Antwort im Hauptchat —
|
||||
# auch wenn der Timer in einem Projekt-Thread gestellt wurde.
|
||||
# Stefan sah dann im Projekt nichts und hielt den Timer fuer
|
||||
# nicht gefeuert.
|
||||
"projectId": project_id or ""},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[trigger-fire] _process_core_response fehlgeschlagen")
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ARIA Postfach</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0A0A14; --panel: #12121E; --line: #1E1E2E; --text: #E0E0F0;
|
||||
--muted: #8888AA; --faint: #555570; --accent: #0096FF; --sel: rgba(0,150,255,0.14);
|
||||
--unread: #E0E0F0; --err: #FF6B6B;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
|
||||
font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
.app { display: grid; grid-template-columns: 190px 360px 1fr; height: 100vh; }
|
||||
.col { border-right: 1px solid var(--line); overflow: auto; min-width: 0; }
|
||||
header { padding: 10px 12px; border-bottom: 1px solid var(--line); font-weight: 700;
|
||||
display: flex; align-items: center; gap: 8px; position: sticky; top: 0; background: var(--bg); z-index: 1; }
|
||||
.folder { padding: 7px 12px; cursor: pointer; color: var(--muted); white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis; }
|
||||
.folder:hover { background: var(--panel); }
|
||||
.folder.active { background: var(--sel); color: var(--accent); font-weight: 600; }
|
||||
.tools { padding: 8px; border-bottom: 1px solid var(--line); display: flex; gap: 6px;
|
||||
position: sticky; top: 41px; background: var(--bg); z-index: 1; }
|
||||
input[type=search] { flex: 1; min-width: 0; background: var(--panel); color: var(--text);
|
||||
border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; font: inherit; }
|
||||
button { background: var(--panel); color: var(--text); border: 1px solid #2A2A3E;
|
||||
border-radius: 4px; padding: 4px 9px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
.msg { padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||
.msg:hover { background: var(--panel); }
|
||||
.msg.active { background: var(--sel); }
|
||||
.msg .from { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; display: flex; gap: 6px; }
|
||||
.msg .from .date { margin-left: auto; color: var(--faint); flex: 0 0 auto; }
|
||||
.msg .subj { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.msg.unread .subj { color: var(--unread); font-weight: 700; }
|
||||
.msg.unread .from::before { content: "●"; color: var(--accent); }
|
||||
.view { display: flex; flex-direction: column; min-width: 0; }
|
||||
.meta { padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||
.meta h2 { margin: 0 0 6px; font-size: 16px; }
|
||||
.meta div { color: var(--muted); font-size: 12px; }
|
||||
.atts { padding: 8px 16px; border-bottom: 1px solid var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.atts a { color: var(--accent); text-decoration: none; background: var(--panel);
|
||||
border: 1px solid #2A2A3E; border-radius: 12px; padding: 2px 10px; font-size: 12px; }
|
||||
.body { flex: 1; min-height: 0; }
|
||||
.body iframe { width: 100%; height: 100%; border: 0; background: #fff; }
|
||||
.body pre { margin: 0; padding: 16px; white-space: pre-wrap; word-break: break-word;
|
||||
font: inherit; height: 100%; overflow: auto; }
|
||||
.empty { padding: 30px; text-align: center; color: var(--faint); }
|
||||
.err { padding: 14px; color: var(--err); }
|
||||
.note { font-size: 11px; color: var(--faint); font-weight: 400; margin-left: auto; }
|
||||
.more { padding: 10px; text-align: center; }
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 40vh 1fr; height: auto; }
|
||||
.col { border-right: 0; border-bottom: 1px solid var(--line); max-height: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="col">
|
||||
<header>📬 ARIA</header>
|
||||
<div id="folders"><div class="empty">Lade …</div></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<header><span id="folder-title">INBOX</span><span class="note" id="count"></span></header>
|
||||
<div class="tools">
|
||||
<input type="search" id="search" placeholder="Suchen …" onkeydown="if(event.key==='Enter')reload()">
|
||||
<button onclick="reload()" title="Neu laden">↻</button>
|
||||
</div>
|
||||
<div id="list"><div class="empty">Lade …</div></div>
|
||||
</div>
|
||||
<div class="col view" id="view">
|
||||
<div class="empty">Mail auswählen.<br><br>
|
||||
<span style="font-size:11px;">Nur lesen — hier wird nichts als gelesen markiert,
|
||||
damit ARIA weiterhin weiß, was neu ist.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// ARIAs Postfach, nur lesend. Holt alles ueber den Brain-Proxy der
|
||||
// Diagnostic (/api/brain/email/*). Zugangsdaten kennt diese Seite nicht.
|
||||
const API = '/api/brain/email';
|
||||
const PAGE = 50;
|
||||
let folder = 'INBOX', offset = 0, total = 0, activeUid = null;
|
||||
let folderRoles = {};
|
||||
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g,
|
||||
c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
async function api(path) {
|
||||
const r = await fetch(API + path);
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
|
||||
return d;
|
||||
}
|
||||
|
||||
function shortDate(s) {
|
||||
const t = Date.parse(s);
|
||||
if (isNaN(t)) return s || '';
|
||||
const d = new Date(t), now = new Date();
|
||||
return d.toDateString() === now.toDateString()
|
||||
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
: d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
}
|
||||
|
||||
function nameOnly(from) {
|
||||
const m = /^\s*"?([^"<]*?)"?\s*<([^>]+)>/.exec(from || '');
|
||||
return m ? (m[1] || m[2]) : (from || '');
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
const box = document.getElementById('folders');
|
||||
try {
|
||||
const d = await api('/folders');
|
||||
folderRoles = Object.fromEntries(d.folders.map(f => [f.name, f.role || '']));
|
||||
const icon = { inbox: '📥 ', sent: '📤 ' };
|
||||
box.innerHTML = d.folders.map(f =>
|
||||
`<div class="folder${f.name === folder ? ' active' : ''}" data-name="${esc(f.name)}"
|
||||
title="${esc(f.label)}" style="padding-left:${12 + 14 * (f.role ? 0 : (f.depth || 0))}px">${icon[f.role] || ''}${esc(f.role ? f.label : (f.short || f.label))}</div>`).join('') || '<div class="empty">Keine Ordner</div>';
|
||||
box.querySelectorAll('.folder').forEach(el => el.onclick = () => selectFolder(el.dataset.name, el.title));
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="err">${esc(e.message)}</div>`;
|
||||
document.getElementById('list').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function selectFolder(name, label) {
|
||||
folder = name;
|
||||
document.getElementById('folder-title').textContent = label || name;
|
||||
document.querySelectorAll('.folder').forEach(el => el.classList.toggle('active', el.dataset.name === name));
|
||||
reload();
|
||||
}
|
||||
|
||||
function reload() { offset = 0; loadList(false); }
|
||||
|
||||
async function loadList(append) {
|
||||
const list = document.getElementById('list');
|
||||
if (!append) list.innerHTML = '<div class="empty">Lade …</div>';
|
||||
const q = document.getElementById('search').value.trim();
|
||||
try {
|
||||
const d = await api(`/messages?folder=${encodeURIComponent(folder)}&limit=${PAGE}&offset=${offset}`
|
||||
+ (q ? `&search=${encodeURIComponent(q)}` : ''));
|
||||
total = d.total;
|
||||
document.getElementById('count').textContent = total ? `${total}` : '';
|
||||
const sent = folderRoles[folder] === 'sent';
|
||||
const html = d.messages.map(m => `
|
||||
<div class="msg${m.seen || sent ? '' : ' unread'}${m.uid === activeUid ? ' active' : ''}" data-uid="${esc(m.uid)}">
|
||||
<div class="from"><span>${sent ? 'an ' + esc(nameOnly(m.to)) : esc(nameOnly(m.from))}</span><span class="date">${esc(shortDate(m.date))}</span></div>
|
||||
<div class="subj">${m.maybe_attachments ? '📎 ' : ''}${esc(m.subject || '(kein Betreff)')}</div>
|
||||
</div>`).join('');
|
||||
if (!append) list.innerHTML = '';
|
||||
list.querySelector('.more')?.remove();
|
||||
list.insertAdjacentHTML('beforeend', html || (append ? '' : '<div class="empty">Keine Mails.</div>'));
|
||||
offset += d.messages.length;
|
||||
if (offset < total) list.insertAdjacentHTML('beforeend',
|
||||
`<div class="more"><button onclick="loadList(true)">Weitere laden (${total - offset})</button></div>`);
|
||||
list.querySelectorAll('.msg').forEach(el => el.onclick = () => openMessage(el.dataset.uid));
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="err">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Mail-HTML in einem Rahmen OHNE Skripte und ohne Zugriff auf diese Seite
|
||||
// (sandbox ohne allow-scripts/allow-same-origin). Die CSP verbietet jedes
|
||||
// Nachladen von aussen — kein Tracking-Pixel meldet "gelesen". Bilder, die
|
||||
// in der Mail stecken, kommen als data: (das Brain baut cid: um).
|
||||
function frameFor(html) {
|
||||
const csp = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:";
|
||||
const doc = `<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<base target="_blank"><style>body{font:14px/1.5 sans-serif;margin:16px;color:#111;}
|
||||
img{max-width:100%;height:auto;}</style></head><body>${html}</body></html>`;
|
||||
const f = document.createElement('iframe');
|
||||
f.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox');
|
||||
f.setAttribute('referrerpolicy', 'no-referrer');
|
||||
f.srcdoc = doc;
|
||||
return f;
|
||||
}
|
||||
|
||||
async function openMessage(uid) {
|
||||
activeUid = uid;
|
||||
document.querySelectorAll('.msg').forEach(el => el.classList.toggle('active', el.dataset.uid === uid));
|
||||
const view = document.getElementById('view');
|
||||
view.innerHTML = '<div class="empty">Lade …</div>';
|
||||
try {
|
||||
const m = await api(`/message?folder=${encodeURIComponent(folder)}&uid=${encodeURIComponent(uid)}`);
|
||||
const atts = m.attachments.length
|
||||
? `<div class="atts">${m.attachments.map(a => `<a href="${API}/attachment?folder=${encodeURIComponent(folder)}&uid=${encodeURIComponent(uid)}&index=${a.index}"
|
||||
download="${esc(a.name)}">📎 ${esc(a.name)} <span style="color:var(--faint)">${Math.max(1, Math.round(a.size / 1024))} KB</span></a>`).join('')}</div>`
|
||||
: '';
|
||||
view.innerHTML = `
|
||||
<div class="meta">
|
||||
<h2>${esc(m.subject || '(kein Betreff)')}</h2>
|
||||
<div>Von: ${esc(m.from)}</div>
|
||||
<div>An: ${esc(m.to)}${m.cc ? ' · Cc: ' + esc(m.cc) : ''}</div>
|
||||
<div>${esc(m.date)}</div>
|
||||
</div>${atts}<div class="body" id="body"></div>`;
|
||||
const body = document.getElementById('body');
|
||||
if (m.html) body.appendChild(frameFor(m.html));
|
||||
else body.innerHTML = `<pre>${esc(m.text || '(leer)')}</pre>`;
|
||||
} catch (e) {
|
||||
view.innerHTML = `<div class="err">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadFolders().then(reload);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1270
-33
File diff suppressed because it is too large
Load Diff
+605
-7
@@ -268,6 +268,13 @@ const RUNTIME_CONFIG_FIELDS = [
|
||||
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
|
||||
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
|
||||
"brainModel",
|
||||
// Ausweich-Modell, wenn Anthropics Sicherheitsfilter das Hauptmodell blockt.
|
||||
// Das Brain liest es PRO Vorfall aus derselben Datei — Aenderung greift ohne
|
||||
// Neustart. Leer = automatisch (anderes Tier als das Hauptmodell).
|
||||
"brainFallbackModel",
|
||||
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
|
||||
// aus derselben Datei — Aenderung greift ohne Neustart.
|
||||
"memoryAttachmentMaxMb",
|
||||
];
|
||||
function readRuntimeConfig() {
|
||||
const envDefaults = {
|
||||
@@ -328,6 +335,87 @@ function writeLocalLlmConfig(patch) {
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
|
||||
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
|
||||
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
|
||||
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
|
||||
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
|
||||
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
|
||||
function readJellyfinConfig() {
|
||||
try {
|
||||
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
|
||||
const servers = Array.isArray(p.servers) ? p.servers : [];
|
||||
return {
|
||||
servers: servers.map(s => ({
|
||||
id: String(s.id || ""),
|
||||
name: String(s.name || ""),
|
||||
url: String(s.url || ""),
|
||||
apiKey: String(s.apiKey || ""),
|
||||
isDefault: !!s.isDefault,
|
||||
})).filter(s => s.id && s.url),
|
||||
};
|
||||
} catch {
|
||||
return { servers: [] };
|
||||
}
|
||||
}
|
||||
function writeJellyfinConfig(cfg) {
|
||||
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
|
||||
.map(s => ({
|
||||
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
|
||||
name: String(s.name || "").trim(),
|
||||
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
|
||||
apiKey: String(s.apiKey || "").trim(),
|
||||
isDefault: !!s.isDefault,
|
||||
}))
|
||||
.filter(s => s.url);
|
||||
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
|
||||
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
|
||||
let seen = false;
|
||||
for (const s of servers) {
|
||||
if (s.isDefault && seen) s.isDefault = false;
|
||||
if (s.isDefault) seen = true;
|
||||
}
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
|
||||
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
|
||||
return { servers };
|
||||
}
|
||||
|
||||
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
|
||||
function testJellyfin(url, apiKey) {
|
||||
return new Promise((resolve) => {
|
||||
let u;
|
||||
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
|
||||
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
|
||||
const mod = u.protocol === "https:" ? require("https") : require("http");
|
||||
const req = mod.request(u, {
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
|
||||
}, (res) => {
|
||||
let body = "";
|
||||
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
|
||||
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
|
||||
req.on("error", (e) => resolve({ ok: false, error: e.message }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
|
||||
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
||||
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
||||
@@ -535,13 +623,50 @@ function broadcastState() {
|
||||
broadcast({ type: "state", state });
|
||||
}
|
||||
|
||||
// ── Anzeigenamen fuer Satelliten + Host-Agenten ──────────────────
|
||||
//
|
||||
// Ein Agent/Satellit nennt sich selbst so, wie es in SEINER .env steht.
|
||||
// Umbenennen hiesse also: auf den Rechner, Datei aendern, neu starten — beim
|
||||
// Fire TV sogar neu provisionieren. Darum ein Alias, der hier zentral liegt.
|
||||
//
|
||||
// Dieselbe Datei nutzt auch die Bridge (sie liegt im geteilten /shared), und
|
||||
// die loest den Alias beim Adressieren wieder auf. Sonst koennte man etwas
|
||||
// umbenennen, es danach aber nicht mehr ansprechen.
|
||||
const DISPLAY_NAMES_FILE = "/shared/config/display_names.json";
|
||||
|
||||
function readDisplayNames() {
|
||||
try {
|
||||
const d = JSON.parse(fs.readFileSync(DISPLAY_NAMES_FILE, "utf-8"));
|
||||
return (d && typeof d === "object") ? d : {};
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
function setDisplayName(kind, id, name) {
|
||||
if (!["host", "satellite"].includes(kind)) throw new Error("kind muss host oder satellite sein");
|
||||
if (!id) throw new Error("id ist Pflicht");
|
||||
const data = readDisplayNames();
|
||||
const sub = (data[kind] && typeof data[kind] === "object") ? data[kind] : {};
|
||||
const clean = String(name || "").trim().slice(0, 60);
|
||||
if (clean) sub[id] = clean; else delete sub[id];
|
||||
data[kind] = sub;
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
const tmp = DISPLAY_NAMES_FILE + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
||||
fs.renameSync(tmp, DISPLAY_NAMES_FILE);
|
||||
log("info", "server", `Anzeigename ${kind} ${id} -> ${clean || "(entfernt)"}`);
|
||||
return clean;
|
||||
}
|
||||
|
||||
// ── Satelliten-Registry (Aussenposten in fremden Netzen) ──────────
|
||||
const satellites = new Map(); // id → {id, location, caps, control, last_seen}
|
||||
|
||||
function satelliteList() {
|
||||
const now = Date.now();
|
||||
const alias = readDisplayNames().satellite || {};
|
||||
return Array.from(satellites.values()).map(s => ({
|
||||
id: s.id, location: s.location, caps: s.caps, control: s.control, net: s.net || null,
|
||||
id: s.id, location: alias[s.id] || s.location,
|
||||
agentName: s.location, alias: alias[s.id] || "",
|
||||
caps: s.caps, control: s.control, net: s.net || null,
|
||||
online: (now - (s.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
@@ -555,8 +680,11 @@ const hosts = new Map(); // hostId → {hostId, name, os, caps, control, last_se
|
||||
|
||||
function hostList() {
|
||||
const now = Date.now();
|
||||
const alias = readDisplayNames().host || {};
|
||||
return Array.from(hosts.values()).map(h => ({
|
||||
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
|
||||
hostId: h.hostId, name: alias[h.hostId] || h.name,
|
||||
agentName: h.name, alias: alias[h.hostId] || "",
|
||||
os: h.os, caps: h.caps, control: h.control,
|
||||
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
|
||||
}));
|
||||
}
|
||||
@@ -575,6 +703,8 @@ function workerList() {
|
||||
return Array.from(workers.values()).map(w => ({
|
||||
instanceId: w.instanceId, service: w.service, node: w.node,
|
||||
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
|
||||
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
|
||||
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
|
||||
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||
}));
|
||||
}
|
||||
@@ -1099,6 +1229,8 @@ function connectRVS(forcePlain) {
|
||||
} else if (msg.type === "rooms_info") {
|
||||
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_openai_config_result") {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
broadcast({ type: "sat_result", payload: msg.payload || {} });
|
||||
@@ -1128,6 +1260,11 @@ function connectRVS(forcePlain) {
|
||||
instanceId: p.instanceId, service: p.service || "",
|
||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
|
||||
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
|
||||
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
|
||||
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
|
||||
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
|
||||
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
|
||||
busy: !!prev.busy, last_seen: Date.now(),
|
||||
});
|
||||
broadcastWorkers();
|
||||
@@ -1938,6 +2075,67 @@ setInterval(checkDiskSpace, 30000);
|
||||
// ── HTTP Server + WebSocket fuer Browser ────────────────
|
||||
|
||||
const htmlPath = path.join(__dirname, "index.html");
|
||||
const mailHtmlPath = path.join(__dirname, "email.html");
|
||||
|
||||
// ── ARIA E-Mail: Admin-Weg zur Konfiguration ────────────────────────
|
||||
// Die Brain-Endpunkte /email/config + /email/test verlangen den Header
|
||||
// X-Aria-Admin. Der Schluessel liegt in ./aria-data/brain/data — hier unter
|
||||
// /brain/data eingehaengt, in ARIAs Container (proxy) NICHT.
|
||||
//
|
||||
// Haken: ARIAs Container erreicht die Diagnostic ueber das Docker-Netz.
|
||||
// Wuerde die Diagnostic den Schluessel blind anhaengen, koennte ARIA per curl
|
||||
// ueber diesen Umweg doch die Whitelist aendern. Darum: Anfragen, die aus
|
||||
// ihrem Container kommen, bekommen den Schluessel nicht.
|
||||
const MAIL_ADMIN_KEY_FILE = "/brain/data/email_admin.key";
|
||||
const dnsPromises = require("dns").promises;
|
||||
|
||||
async function requestFromAriaContainer(req) {
|
||||
const ip = String(req.socket.remoteAddress || "").replace(/^::ffff:/, "");
|
||||
for (const host of ["aria-proxy", "proxy"]) {
|
||||
try {
|
||||
const addrs = await dnsPromises.lookup(host, { all: true });
|
||||
if (addrs.some(a => a.address === ip)) return true;
|
||||
} catch (_) { /* Name unbekannt → naechster */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mailAdminKey() {
|
||||
try { return fs.readFileSync(MAIL_ADMIN_KEY_FILE, "utf-8").trim(); } catch (_) { return ""; }
|
||||
}
|
||||
|
||||
async function handleMailAdmin(req, res, brainPath) {
|
||||
const reply = (code, obj) => {
|
||||
res.writeHead(code, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (await requestFromAriaContainer(req)) {
|
||||
log("warn", "server", `E-Mail-Konfiguration: Zugriff aus ARIAs Container abgewiesen (${req.method} ${brainPath})`);
|
||||
return reply(403, { detail: "Die E-Mail-Konfiguration aendert nur Stefan in der Diagnostic." });
|
||||
}
|
||||
const key = mailAdminKey();
|
||||
if (!key) return reply(503, { detail: "Brain hat noch keinen Admin-Schluessel angelegt — laeuft aria-brain?" });
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
const proxyReq = http.request({
|
||||
host: "aria-brain", port: 8080, path: brainPath, method: req.method,
|
||||
headers: { "Content-Type": "application/json", "X-Aria-Admin": key,
|
||||
...(body ? { "Content-Length": Buffer.byteLength(body) } : {}) },
|
||||
timeout: 60000,
|
||||
}, (pr) => {
|
||||
let out = "";
|
||||
pr.on("data", c => out += c);
|
||||
pr.on("end", () => {
|
||||
res.writeHead(pr.statusCode || 502, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
||||
res.end(out);
|
||||
});
|
||||
});
|
||||
proxyReq.on("error", (err) => reply(503, { detail: "Brain nicht erreichbar: " + err.message }));
|
||||
if (body) proxyReq.write(body);
|
||||
proxyReq.end();
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/" || req.url === "/index.html") {
|
||||
@@ -1952,6 +2150,16 @@ const server = http.createServer((req, res) => {
|
||||
"Expires": "0",
|
||||
});
|
||||
res.end(fs.readFileSync(htmlPath, "utf-8"));
|
||||
} else if (req.url === "/email.html") {
|
||||
// ARIAs Postfach in eigenem Fenster (Button in Einstellungen → ARIA E-Mail).
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
||||
res.end(fs.readFileSync(mailHtmlPath, "utf-8"));
|
||||
} else if (req.url === "/api/email-admin/config" && (req.method === "GET" || req.method === "POST")) {
|
||||
handleMailAdmin(req, res, "/email/config");
|
||||
return;
|
||||
} else if (req.url === "/api/email-admin/test" && req.method === "POST") {
|
||||
handleMailAdmin(req, res, "/email/test");
|
||||
return;
|
||||
} else if (req.url.startsWith("/uploads/")) {
|
||||
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
|
||||
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
|
||||
@@ -2026,6 +2234,39 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(readJellyfinConfig()));
|
||||
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const cfg = writeJellyfinConfig(JSON.parse(body));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, config: cfg }));
|
||||
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const { url, apiKey } = JSON.parse(body);
|
||||
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(r));
|
||||
} catch (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if (req.url === "/api/onboarding") {
|
||||
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -2078,22 +2319,27 @@ const server = http.createServer((req, res) => {
|
||||
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||
}
|
||||
return;
|
||||
} else if (req.url === "/api/models-list" && req.method === "GET") {
|
||||
} else if (req.url.startsWith("/api/models-list") && req.method === "GET") {
|
||||
// Kuratierte Model-Liste vom Proxy (/v1/models) — Tier-Auswahl fuers
|
||||
// Sprachmodell-Dropdown. ARIA laeuft ueber das Max-Abo/CLI, waehlbar ist
|
||||
// der Tier (opus/sonnet/haiku), keine feste Version.
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`${PROXY_URL}/v1/models`);
|
||||
// ?refresh=1 weiterreichen: der Proxy umgeht damit seinen Katalog-Cache
|
||||
// und holt die Liste frisch von Anthropic („↻ Aktualisieren").
|
||||
const refresh = /[?&]refresh=1\b/.test(req.url) ? "?refresh=1" : "";
|
||||
const r = await fetch(`${PROXY_URL}/v1/models${refresh}`);
|
||||
const d = await r.json();
|
||||
const models = (d.data || []).map(m => ({
|
||||
id: m.id,
|
||||
tier: m.tier || m.id,
|
||||
displayName: m.display_name || m.id,
|
||||
description: m.description || "",
|
||||
// section: tier | main | overflow — die UI gruppiert danach.
|
||||
section: m.section || "tier",
|
||||
}));
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, models }));
|
||||
res.end(JSON.stringify({ ok: true, models, catalogOk: d.catalog_ok !== false }));
|
||||
} catch (err) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
|
||||
@@ -2660,7 +2906,13 @@ const server = http.createServer((req, res) => {
|
||||
"Content-Disposition": `attachment; filename="aria-brain-${ts}.tar.gz"`,
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
const tar = spawn("tar", ["czf", "-", "-C", "/brain", "."]);
|
||||
// E-Mail-Passwort + Admin-Schluessel NICHT ins Backup: ein Export
|
||||
// wandert auf andere Rechner, in Cloud-Ordner, an Kumpels. Nach einem
|
||||
// Import traegt Stefan das Passwort neu ein, der Schluessel entsteht
|
||||
// beim Brain-Start von selbst.
|
||||
const tar = spawn("tar", ["czf", "-", "-C", "/brain",
|
||||
"--exclude=./data/email_secret.json",
|
||||
"--exclude=./data/email_admin.key", "."]);
|
||||
tar.stdout.pipe(res);
|
||||
let stderr = "";
|
||||
tar.stderr.on("data", d => stderr += d.toString());
|
||||
@@ -2850,6 +3102,17 @@ wss.on("connection", (ws) => {
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "llm_openai_config") {
|
||||
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
|
||||
sendToRVS_raw({
|
||||
type: "llm_openai_config",
|
||||
payload: {
|
||||
instanceId: String(msg.instanceId || ""),
|
||||
apiKey: String(msg.apiKey || ""),
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "agent_history_query") {
|
||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||
@@ -2863,6 +3126,23 @@ wss.on("connection", (ws) => {
|
||||
sendToRVS_raw({ type: "sat_discover",
|
||||
payload: { satellite: msg.satellite || "", force: true },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "set_display_name") {
|
||||
// Anzeigename fuer einen Satelliten oder Host-Agenten.
|
||||
try {
|
||||
setDisplayName(msg.kind, msg.id, msg.name);
|
||||
if (msg.kind === "satellite") broadcastSatellites(); else broadcastHosts();
|
||||
} catch (err) {
|
||||
ws.send(JSON.stringify({ type: "log", level: "error", source: "server",
|
||||
message: "Umbenennen: " + err.message }));
|
||||
}
|
||||
} else if (msg.action === "sat_name_set") {
|
||||
// Geraetename vergeben/entfernen. Der Satellit haengt ihn an die MAC,
|
||||
// damit ein IP-Wechsel den Namen nicht verliert.
|
||||
sendToRVS_raw({ type: "sat_name_set",
|
||||
payload: { satellite: msg.satellite || "", mac: msg.mac || "",
|
||||
ip: msg.ip || "", name: msg.name || "",
|
||||
requestId: "dn_" + Date.now() },
|
||||
timestamp: Date.now() });
|
||||
} else if (msg.action === "sat_creds_list") {
|
||||
sendToRVS_raw({ type: "sat_creds_list",
|
||||
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
|
||||
@@ -3073,6 +3353,20 @@ wss.on("connection", (ws) => {
|
||||
handleGetModel(ws);
|
||||
} else if (msg.action === "set_model") {
|
||||
handleSetModel(ws, msg.model);
|
||||
} else if (msg.action === "test_model") {
|
||||
handleTestModel(ws, msg.model);
|
||||
} else if (msg.action === "set_fallback_model") {
|
||||
handleSetFallbackModel(ws, msg.model);
|
||||
} else if (msg.action === "check_proxy_patches") {
|
||||
handleCheckProxyPatches(ws);
|
||||
} else if (msg.action === "get_privacy_flags") {
|
||||
handleGetPrivacyFlags(ws);
|
||||
} else if (msg.action === "set_privacy_flags") {
|
||||
handleSetPrivacyFlags(ws, msg.flags);
|
||||
} else if (msg.action === "get_attachment_limit") {
|
||||
handleGetAttachmentLimit(ws);
|
||||
} else if (msg.action === "set_attachment_limit") {
|
||||
handleSetAttachmentLimit(ws, msg.maxMb);
|
||||
} else if (msg.action === "voice_id_status") {
|
||||
// An whisper-bridge weiterleiten + Antwort an Browser zurueck
|
||||
const reqId = `vid_${Date.now().toString(36)}`;
|
||||
@@ -3364,6 +3658,8 @@ async function handleLoadChatHistory(clientWs) {
|
||||
const text = String(obj.text || "");
|
||||
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
|
||||
const answeredBy = String(obj.answeredBy || ""); // Quell-Badge (local/claude/fast-path)
|
||||
const model = String(obj.model || ""); // welches Claude-Modell
|
||||
const modelReason = String(obj.modelReason || ""); // einstellung/pro-chat/ausweich
|
||||
if (obj.role === "user") {
|
||||
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
|
||||
continue;
|
||||
@@ -3390,7 +3686,7 @@ async function handleLoadChatHistory(clientWs) {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId, answeredBy });
|
||||
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId, answeredBy, model, modelReason });
|
||||
}
|
||||
|
||||
clientWs.send(JSON.stringify({ type: "chat_history", messages }));
|
||||
@@ -3469,6 +3765,7 @@ function handleGetModel(clientWs) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_info",
|
||||
model,
|
||||
fallbackModel: (cfg.brainFallbackModel || "").trim(),
|
||||
info: "Brain-Model (runtime.json) — bei Aenderung: aria-brain restarten",
|
||||
}));
|
||||
} catch (err) {
|
||||
@@ -3476,6 +3773,29 @@ function handleGetModel(clientWs) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ausweich-Modell fuer Anthropics Sicherheitsfilter. Leerer String =
|
||||
// automatisch (das Brain nimmt dann ein anderes Tier als das Hauptmodell).
|
||||
// Anders als brainModel braucht das KEINEN Neustart: safeguard.py liest die
|
||||
// Datei erst, wenn der Filter wirklich zuschlaegt.
|
||||
function handleSetFallbackModel(clientWs, model) {
|
||||
try {
|
||||
const val = typeof model === "string" ? model.trim() : "";
|
||||
writeRuntimeConfig({ brainFallbackModel: val });
|
||||
log("info", "server", `Ausweich-Model gesetzt: ${val || "(automatisch)"}`);
|
||||
const cfg = readRuntimeConfig();
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_info",
|
||||
model: (cfg.brainModel || "").trim() || "claude-sonnet-4",
|
||||
fallbackModel: val,
|
||||
info: val
|
||||
? `Ausweich-Model: ${val} — greift sofort, kein Neustart noetig`
|
||||
: "Ausweich-Model: automatisch (anderes Tier als das Hauptmodell)",
|
||||
}));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "model_info", error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetModel(clientWs, model) {
|
||||
if (!model || typeof model !== "string") {
|
||||
clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" }));
|
||||
@@ -3495,6 +3815,284 @@ function handleSetModel(clientWs, model) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Laufen im Proxy die aktuellen Patches? ──────────────────────────
|
||||
//
|
||||
// Der Proxy kopiert proxy-patches/*.js beim START ueber den npm-Code (siehe
|
||||
// docker-compose.yml, command:). Wer nach einem git pull nur `restart` macht —
|
||||
// oder neu startet, BEVOR der Pull durch ist — laeuft still mit der alten
|
||||
// Fassung weiter. Das faellt sonst nirgends auf: der Container ist "Up", die
|
||||
// Oberflaeche sieht normal aus, nur wirkt die Aenderung nicht.
|
||||
//
|
||||
// Darum: Zeitstempel der Quelldateien gegen die eingespielten vergleichen.
|
||||
const PROXY_PATCH_MAP = [
|
||||
["routes.js", "server/routes.js"],
|
||||
["manager.js", "subprocess/manager.js"],
|
||||
["openai-to-cli.js", "adapter/openai-to-cli.js"],
|
||||
["cli-to-openai.js", "adapter/cli-to-openai.js"],
|
||||
];
|
||||
|
||||
async function handleCheckProxyPatches(clientWs) {
|
||||
try {
|
||||
const dist = "/usr/local/lib/node_modules/claude-max-api-proxy/dist";
|
||||
const pairs = PROXY_PATCH_MAP
|
||||
.map(([src, dst]) => `/proxy-patches/${src} ${dist}/${dst}`).join(" ");
|
||||
// %Y = mtime als Unix-Zeit, %n = Dateiname
|
||||
const out = await dockerExec("aria-proxy", `stat -c '%Y %n' ${pairs} 2>/dev/null || true`);
|
||||
const mtime = {};
|
||||
for (const line of String(out || "").split("\n")) {
|
||||
const m = line.trim().match(/^(\d+)\s+(.+)$/);
|
||||
if (m) mtime[m[2]] = parseInt(m[1], 10);
|
||||
}
|
||||
const stale = [];
|
||||
for (const [src, dst] of PROXY_PATCH_MAP) {
|
||||
const a = mtime[`/proxy-patches/${src}`];
|
||||
const b = mtime[`${dist}/${dst}`];
|
||||
if (a == null || b == null) continue;
|
||||
// Toleranz 5s: Quelle und Kopie entstehen beim Start kurz nacheinander.
|
||||
if (a > b + 5) stale.push({ file: src, srcAt: a * 1000, activeAt: b * 1000 });
|
||||
}
|
||||
clientWs.send(JSON.stringify({ type: "proxy_patch_state", stale }));
|
||||
} catch (err) {
|
||||
// Kein Alarm bei einem Pruef-Fehler — sonst steht da eine Warnung, die
|
||||
// gar nichts ueber die Patches aussagt.
|
||||
clientWs.send(JSON.stringify({ type: "proxy_patch_state", stale: [], error: String((err && err.message) || err) }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Einstellungen: Datenschutz-Schalter von Claude Code ─────────────
|
||||
//
|
||||
// Drei Kanaele, ueber die Claude Code von sich aus Daten an Anthropic bzw. an
|
||||
// einen Fehler-Dienst schickt. Sie liegen als Umgebungsvariablen vor und lassen
|
||||
// sich in ~/.claude/settings.json unter "env" setzen — die Datei ist in den
|
||||
// Proxy-Container gemountet, also koennen wir sie von hier aus pflegen, ohne
|
||||
// die docker-compose.yml anzufassen.
|
||||
//
|
||||
// WICHTIG: gesetzt wird in settings.json. Steht dieselbe Variable ZUSAETZLICH
|
||||
// als echte Container-Umgebungsvariable (docker-compose), gewinnt die — darum
|
||||
// prueft "Pruefen" beides und sagt, was tatsaechlich im Prozess ankommt.
|
||||
const PRIVACY_FLAGS = [
|
||||
{ key: "DISABLE_ERROR_REPORTING",
|
||||
label: "Fehlerberichte",
|
||||
info: "Stacktraces an einen Fehler-Dienst. Bei Pro/Max ab CLI 2.1.198 standardmäßig AN." },
|
||||
{ key: "DISABLE_TELEMETRY",
|
||||
label: "Nutzungs-Metriken",
|
||||
info: "Latenz/Zuverlässigkeit. Enthält laut Anthropic nie Code, Prompts oder Pfade." },
|
||||
{ key: "DISABLE_FEEDBACK_COMMAND",
|
||||
label: "/feedback-Versand",
|
||||
info: "Sendet sonst auf Zuruf die ganze Unterhaltung inkl. Code — 5 Jahre Aufbewahrung." },
|
||||
];
|
||||
const CLAUDE_SETTINGS_PATH = "/root/.claude/settings.json";
|
||||
|
||||
function _shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
||||
|
||||
async function handleGetPrivacyFlags(clientWs) {
|
||||
try {
|
||||
// 1) Was kommt im PROZESS wirklich an? Das ist die Wahrheit.
|
||||
const envOut = await dockerExec("aria-proxy",
|
||||
"env | grep -E '^(DISABLE_ERROR_REPORTING|DISABLE_TELEMETRY|DISABLE_FEEDBACK_COMMAND|CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC)=' || true");
|
||||
const liveEnv = {};
|
||||
for (const line of String(envOut || "").split("\n")) {
|
||||
const i = line.indexOf("=");
|
||||
if (i > 0) liveEnv[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
// 2) Was steht in settings.json?
|
||||
const settingsRaw = await dockerExec("aria-proxy",
|
||||
`cat ${CLAUDE_SETTINGS_PATH} 2>/dev/null || echo '{}'`);
|
||||
let settingsEnv = {};
|
||||
try { settingsEnv = (JSON.parse(settingsRaw || "{}").env) || {}; } catch (_) {}
|
||||
|
||||
const isOn = (v) => v === "1" || String(v).toLowerCase() === "true";
|
||||
const flags = PRIVACY_FLAGS.map(f => {
|
||||
const fromEnv = liveEnv[f.key];
|
||||
const fromSettings = settingsEnv[f.key];
|
||||
// Container-Env gewinnt gegen settings.json.
|
||||
const effective = fromEnv !== undefined ? isOn(fromEnv) : isOn(fromSettings);
|
||||
return {
|
||||
key: f.key, label: f.label, info: f.info,
|
||||
disabled: effective,
|
||||
source: fromEnv !== undefined ? "container" : (fromSettings !== undefined ? "settings" : "none"),
|
||||
};
|
||||
});
|
||||
const bulk = isOn(liveEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC);
|
||||
clientWs.send(JSON.stringify({ type: "privacy_flags", flags, bulkDisabled: bulk }));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "privacy_flags", error: String((err && err.message) || err) }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetPrivacyFlags(clientWs, wanted) {
|
||||
try {
|
||||
const keys = PRIVACY_FLAGS.map(f => f.key);
|
||||
const raw = await dockerExec("aria-proxy",
|
||||
`cat ${CLAUDE_SETTINGS_PATH} 2>/dev/null || echo '{}'`);
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(raw || "{}"); } catch (_) { cfg = {}; }
|
||||
if (!cfg.env || typeof cfg.env !== "object") cfg.env = {};
|
||||
for (const k of keys) {
|
||||
if (wanted && wanted[k]) cfg.env[k] = "1";
|
||||
else delete cfg.env[k];
|
||||
}
|
||||
if (Object.keys(cfg.env).length === 0) delete cfg.env;
|
||||
|
||||
// Atomar schreiben (temp + mv), damit eine laufende CLI nie eine halbe
|
||||
// Datei liest.
|
||||
const json = JSON.stringify(cfg, null, 2);
|
||||
await dockerExec("aria-proxy",
|
||||
`printf '%s' ${_shq(json)} > ${CLAUDE_SETTINGS_PATH}.tmp && mv ${CLAUDE_SETTINGS_PATH}.tmp ${CLAUDE_SETTINGS_PATH}`);
|
||||
log("info", "server", `Datenschutz-Schalter gesetzt: ${JSON.stringify(wanted || {})}`);
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "privacy_flags_saved",
|
||||
info: "Gespeichert. Gilt für JEDE neue Anfrage — ein laufender Turn nutzt noch die alte Einstellung.",
|
||||
}));
|
||||
await handleGetPrivacyFlags(clientWs);
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "privacy_flags_saved", error: String((err && err.message) || err) }));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Einstellungen: Model-Test ───────────────────────────────────────
|
||||
//
|
||||
// Schickt eine winzige ECHTE Anfrage durch den kompletten Weg
|
||||
// (Diagnostic -> Proxy -> extractModel -> claude CLI -> Anthropic) und meldet,
|
||||
// welches Modell tatsaechlich geantwortet hat.
|
||||
//
|
||||
// Wichtig: wir fragen NICHT das Modell selbst ("welche Version bist du?") —
|
||||
// darauf kann man sich nicht verlassen, Modelle kennen ihren eigenen
|
||||
// Versionsstring oft nicht und raten. Die Wahrheit kommt aus modelUsage der
|
||||
// CLI (Feld canonicalModel): das ist der Name, unter dem der Call wirklich
|
||||
// lief. Bei einem Alias sieht man damit die aufgeloeste Version
|
||||
// ("opus" -> "claude-opus-5-5"), bei einem ungueltigen Namen den Fehler.
|
||||
function handleTestModel(clientWs, model) {
|
||||
const m = String(model || "").trim();
|
||||
if (!m) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result", error: "Kein Modell angegeben" }));
|
||||
return;
|
||||
}
|
||||
const started = Date.now();
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`${PROXY_URL}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: m,
|
||||
// Kurz halten: der Test soll Sekunden kosten, nicht Minuten.
|
||||
messages: [{ role: "user", content: "Antworte nur mit: OK" }],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
});
|
||||
const d = await r.json().catch(() => null);
|
||||
if (!r.ok || !d) {
|
||||
throw new Error(`Proxy antwortete HTTP ${r.status}`);
|
||||
}
|
||||
const tookMs = Date.now() - started;
|
||||
const reply = (d.choices && d.choices[0] && d.choices[0].message
|
||||
&& d.choices[0].message.content) || "";
|
||||
// Kein Modell in modelUsage ODER is_error => der Call kam nie
|
||||
// zustande. Die CLI legt ihre Begruendung dann in den Text.
|
||||
if (d.aria_error || !d.aria_model) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result",
|
||||
requested: m,
|
||||
error: (reply || "Modell hat nicht geantwortet").slice(0, 400),
|
||||
tookMs,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result",
|
||||
requested: m,
|
||||
actual: d.aria_model,
|
||||
contextWindow: d.aria_context_window || 0,
|
||||
maxOutput: d.aria_max_output || 0,
|
||||
reply: reply.slice(0, 120),
|
||||
tookMs,
|
||||
}));
|
||||
log("info", "server", `Model-Test: ${m} -> ${d.aria_model} (${tookMs}ms)`);
|
||||
} catch (err) {
|
||||
const msg = err && err.name === "TimeoutError"
|
||||
? "Zeitüberschreitung (3 min) — Proxy oder CLI antwortet nicht"
|
||||
: String((err && err.message) || err);
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "model_test_result", requested: m, error: msg,
|
||||
tookMs: Date.now() - started,
|
||||
}));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Einstellungen: Groessen-Limit fuer Memory-Anhaenge ──────────────
|
||||
// Steht in runtime.json als `memoryAttachmentMaxMb`. Das Brain liest es bei
|
||||
// JEDEM Upload neu (memory_attachments.max_bytes()) — eine Aenderung greift
|
||||
// also sofort, ohne aria-brain neu zu starten.
|
||||
//
|
||||
// Obergrenze mit Bedacht: der Anhang geht als base64-in-JSON zur App, die muss
|
||||
// ihn komplett in den Speicher nehmen. Ueber ~64 MB laedt das Handy ihn nicht
|
||||
// mehr (TAP_DL_MAX_BYTES in ChatScreen.tsx) — dann liegt er zwar sauber auf dem
|
||||
// Server, ist mobil aber nur noch per Dateimanager erreichbar. Darum warnen wir
|
||||
// darueber, verbieten es aber nicht.
|
||||
const ATTACHMENT_LIMIT_SOFT_MB = 64;
|
||||
const ATTACHMENT_LIMIT_MAX_MB = 1024;
|
||||
|
||||
function handleGetAttachmentLimit(clientWs) {
|
||||
// Das Brain ist die Wahrheit — es kennt auch den ENV-Fallback, den der
|
||||
// Diagnostic-Container nicht sieht.
|
||||
const brain = process.env.BRAIN_URL || "http://aria-brain:8080";
|
||||
const req = http.get(`${brain}/attachments/limit`, { timeout: 5000 }, (r) => {
|
||||
let body = "";
|
||||
r.on("data", (c) => { body += c; });
|
||||
r.on("end", () => {
|
||||
let maxMb = null;
|
||||
try { maxMb = JSON.parse(body).maxMb; } catch (_) {}
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info",
|
||||
maxMb,
|
||||
softMb: ATTACHMENT_LIMIT_SOFT_MB,
|
||||
hardMb: ATTACHMENT_LIMIT_MAX_MB,
|
||||
error: maxMb == null ? "Brain antwortet nicht verwertbar" : undefined,
|
||||
}));
|
||||
});
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(new Error("Brain antwortet nicht")); });
|
||||
}
|
||||
|
||||
function handleSetAttachmentLimit(clientWs, maxMb) {
|
||||
const n = Number(maxMb);
|
||||
if (!isFinite(n) || n <= 0) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info", error: "Bitte eine Zahl groesser 0 angeben (MB)" }));
|
||||
return;
|
||||
}
|
||||
if (n > ATTACHMENT_LIMIT_MAX_MB) {
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info",
|
||||
error: `Maximal ${ATTACHMENT_LIMIT_MAX_MB} MB — darueber reisst der WebSocket-Transport` }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
writeRuntimeConfig({ memoryAttachmentMaxMb: n });
|
||||
log("info", "server", `Anhang-Limit gesetzt: ${n} MB`);
|
||||
clientWs.send(JSON.stringify({
|
||||
type: "attachment_limit_info",
|
||||
maxMb: n,
|
||||
softMb: ATTACHMENT_LIMIT_SOFT_MB,
|
||||
hardMb: ATTACHMENT_LIMIT_MAX_MB,
|
||||
info: n > ATTACHMENT_LIMIT_SOFT_MB
|
||||
? `${n} MB gesetzt (greift sofort). Hinweis: ueber ${ATTACHMENT_LIMIT_SOFT_MB} MB `
|
||||
+ `laedt die Handy-App Anhaenge nicht mehr herunter — sie liegen dann nur `
|
||||
+ `noch auf dem Server.`
|
||||
: `${n} MB gesetzt — greift sofort, kein Neustart noetig.`,
|
||||
}));
|
||||
} catch (err) {
|
||||
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw-Config-Handler entfernt — aria-core ist raus.
|
||||
|
||||
// ── Start ───────────────────────────────────────────────
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 102
|
||||
versionName '0.0.1.2'
|
||||
versionCode 105
|
||||
versionName '0.0.1.5'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
|
||||
+23
@@ -44,6 +44,29 @@ class AriaAccessibilityService : AccessibilityService() {
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
|
||||
override fun onInterrupt() { /* passiv */ }
|
||||
|
||||
/**
|
||||
* Steht eine Einblendung offen, raeumt sie der naechste Tastendruck weg.
|
||||
*
|
||||
* Der Trick: wir geben IMMER `false` zurueck. Damit laeuft die Taste ganz
|
||||
* normal weiter an den Film/die App — die Einblendung schluckt nichts. Sie
|
||||
* muss dafuer auch nicht fokussierbar sein, kann also weiterhin ueber einem
|
||||
* laufenden Video stehen, ohne die Fernbedienung zu blockieren.
|
||||
*
|
||||
* Deshalb dieser Weg und nicht "Overlay faengt Tasten selbst ab": ein
|
||||
* fokussierbares Fenster wuerde der App darunter JEDE Eingabe nehmen,
|
||||
* solange es steht.
|
||||
*
|
||||
* Eine feste Anzeigedauer ist naemlich immer falsch — mal zu kurz zum
|
||||
* Lesen, mal steht sie ewig im Bild.
|
||||
*/
|
||||
override fun onKeyEvent(event: android.view.KeyEvent?): Boolean {
|
||||
if (event != null && event.action == android.view.KeyEvent.ACTION_DOWN
|
||||
&& OverlayView.isShowing()) {
|
||||
OverlayView.hide(applicationContext)
|
||||
}
|
||||
return false // Taste NICHT verbrauchen
|
||||
}
|
||||
|
||||
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
|
||||
fun dump(): JSONObject {
|
||||
val root = rootInActiveWindow
|
||||
|
||||
@@ -25,13 +25,33 @@ import android.widget.TextView
|
||||
*
|
||||
* Nicht fokussierbar (FLAG_NOT_FOCUSABLE) — die Fernbedienung steuert weiter die
|
||||
* App darunter, die Einblendung schluckt keine Tastendruecke.
|
||||
*
|
||||
* WEGBEKOMMEN ohne feste Anzeigedauer (seconds = 0):
|
||||
* - Fernbedienung: der Accessibility-Dienst sieht den naechsten Tastendruck und
|
||||
* raeumt die Einblendung weg — er gibt die Taste trotzdem an den Film weiter
|
||||
* (siehe AriaAccessibilityService.onKeyEvent).
|
||||
* - Touch: FLAG_WATCH_OUTSIDE_TOUCH meldet uns eine Beruehrung AUSSERHALB der
|
||||
* Einblendung. Die Beruehrung geht gleichzeitig an die App darunter — es
|
||||
* geht also kein Tipp verloren.
|
||||
* - Notbremse: nach HARD_CAP_SEC verschwindet sie in jedem Fall. Ohne das
|
||||
* koennte eine vergessene Einblendung dauerhaft im Bild stehen.
|
||||
*
|
||||
* Eine feste Dauer ist naemlich immer falsch geraten — mal zu kurz zum Lesen,
|
||||
* mal steht sie laengst im Weg.
|
||||
*/
|
||||
object OverlayView {
|
||||
|
||||
/** Auch eine "bleibt stehen"-Einblendung geht spaetestens danach weg. */
|
||||
private const val HARD_CAP_SEC = 600
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var root: LinearLayout? = null
|
||||
private var hideRunnable: Runnable? = null
|
||||
|
||||
/** Steht gerade eine Einblendung? Der Accessibility-Dienst fragt das, bevor
|
||||
* er auf einen Tastendruck hin aufraeumt. */
|
||||
fun isShowing(): Boolean = root != null
|
||||
|
||||
/** Hat der Nutzer "Ueber anderen Apps anzeigen" erlaubt? */
|
||||
fun canDraw(ctx: Context): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
|
||||
@@ -98,6 +118,10 @@ object OverlayView {
|
||||
type,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
// Meldet uns Beruehrungen AUSSERHALB, ohne sie zu
|
||||
// schlucken — damit raeumt ein Tipp aufs Video die
|
||||
// Einblendung weg und steuert trotzdem den Player.
|
||||
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
android.graphics.PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
@@ -107,13 +131,23 @@ object OverlayView {
|
||||
width = (ctx.resources.displayMetrics.widthPixels * 0.42f).toInt()
|
||||
}
|
||||
|
||||
// Beruehrung ausserhalb -> wegraeumen. Die Beruehrung selbst
|
||||
// geht dank FLAG_NOT_TOUCHABLE weiter an die App darunter.
|
||||
box.setOnTouchListener { _, ev ->
|
||||
if (ev.action == android.view.MotionEvent.ACTION_OUTSIDE) hideNow(ctx)
|
||||
false
|
||||
}
|
||||
|
||||
wm.addView(box, lp)
|
||||
root = box
|
||||
|
||||
if (seconds > 0) {
|
||||
hideRunnable = Runnable { hideNow(ctx) }
|
||||
main.postDelayed(hideRunnable!!, seconds * 1000L)
|
||||
}
|
||||
// seconds > 0 : feste Dauer wie gehabt.
|
||||
// seconds == 0: bleibt stehen, bis der Nutzer etwas tut
|
||||
// (Taste via Accessibility-Dienst / Tipp daneben)
|
||||
// — mit Notbremse, damit sie nie haengen bleibt.
|
||||
val cap = if (seconds > 0) seconds else HARD_CAP_SEC
|
||||
hideRunnable = Runnable { hideNow(ctx) }
|
||||
main.postDelayed(hideRunnable!!, cap * 1000L)
|
||||
} catch (_: Exception) {
|
||||
root = null
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class RvsClient(
|
||||
/** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */
|
||||
val voice: VoiceSession by lazy {
|
||||
VoiceSession(appCtx, config,
|
||||
send = { t, p -> ws?.let { send(it, t, p) } },
|
||||
send = { t, p -> ws?.let { send(it, t, p) } ?: false },
|
||||
onEvent = { msg -> onStatus(true, msg) })
|
||||
}
|
||||
|
||||
@@ -112,12 +112,21 @@ class RvsClient(
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
|
||||
/**
|
||||
* Verschickt eine Nachricht. Gibt zurueck, ob der Socket sie ANGENOMMEN hat.
|
||||
*
|
||||
* Wichtig: OkHttps `send()` WIRFT NICHT, sondern gibt `false` zurueck, wenn
|
||||
* die Verbindung zu ist oder der Ausgangspuffer voll ist. Dieser Rueckgabe-
|
||||
* wert wurde vorher verworfen — ein Senden ins Leere sah damit aus wie ein
|
||||
* Erfolg. Genau daran ist die Fire-TV-Sprachausgabe lautlos gescheitert und
|
||||
* hat trotzdem "gesprochen" gemeldet.
|
||||
*/
|
||||
private fun send(webSocket: WebSocket, type: String, payload: JSONObject): Boolean {
|
||||
val o = JSONObject()
|
||||
o.put("type", type)
|
||||
o.put("payload", payload)
|
||||
o.put("timestamp", System.currentTimeMillis())
|
||||
try { webSocket.send(o.toString()) } catch (_: Exception) {}
|
||||
return try { webSocket.send(o.toString()) } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun sendHello(webSocket: WebSocket) {
|
||||
@@ -178,6 +187,31 @@ class RvsClient(
|
||||
|
||||
val action = payload.optString("action")
|
||||
val params = payload.optJSONObject("params") ?: JSONObject()
|
||||
|
||||
fun reply(r: JSONObject) {
|
||||
r.put("requestId", payload.optString("requestId"))
|
||||
r.put("hostId", config.hostId())
|
||||
r.put("action", action)
|
||||
send(webSocket, "host_result", r)
|
||||
}
|
||||
|
||||
// Alles, was SPRECHEN kann, laeuft in einem EIGENEN Thread. Es muss
|
||||
// abwarten, bis wirklich Ton kommt — und dieses handle() laeuft auf dem
|
||||
// Lese-Thread des WebSockets. Wuerden wir hier warten, kaeme das
|
||||
// erwartete audio_pcm nie an (wir blockieren ja selbst das Lesen) und
|
||||
// jede Sprachausgabe liefe garantiert in den Timeout.
|
||||
if (action in SPEAKING_ACTIONS && config.controlEnabled) {
|
||||
Thread {
|
||||
val r = try {
|
||||
if (action == "speak") doSpeak(params) else doNotify(params)
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
reply(r)
|
||||
}.also { it.isDaemon = true }.start()
|
||||
return
|
||||
}
|
||||
|
||||
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
|
||||
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
|
||||
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
|
||||
@@ -210,15 +244,16 @@ class RvsClient(
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
result.put("action", action)
|
||||
send(webSocket, "host_result", result)
|
||||
reply(result)
|
||||
}
|
||||
|
||||
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
|
||||
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
|
||||
|
||||
/** Aktionen, die sprechen koennen — laufen abseits des WS-Lese-Threads,
|
||||
* weil sie auf das eintreffende audio_pcm warten (siehe handle()). */
|
||||
private val SPEAKING_ACTIONS = setOf("speak", "notify")
|
||||
|
||||
/** Message-Typen des ARIA-Sprachdialogs (kein host_command). */
|
||||
private val VOICE_TYPES = setOf(
|
||||
"chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial")
|
||||
@@ -331,7 +366,7 @@ class RvsClient(
|
||||
if (mode == "speech" || mode == "both") {
|
||||
val r = speakOut(p, text)
|
||||
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
|
||||
?: run { done += "gesprochen" }
|
||||
?: run { done += "gesprochen (ARIAs Stimme)" }
|
||||
}
|
||||
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
|
||||
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
|
||||
@@ -342,21 +377,38 @@ class RvsClient(
|
||||
private fun doSpeak(p: JSONObject): JSONObject {
|
||||
val text = p.optString("text")
|
||||
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
|
||||
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
|
||||
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
|
||||
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
|
||||
val r = speakOut(p, text)
|
||||
return r.fold(
|
||||
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", "gesprochen")) },
|
||||
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
|
||||
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
|
||||
)
|
||||
}
|
||||
|
||||
/** Audio bevorzugt (ARIAs echte Stimme), sonst Geraete-TTS.
|
||||
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
|
||||
/**
|
||||
* Sprachausgabe. Reihenfolge:
|
||||
* 1. mitgeliefertes Audio abspielen,
|
||||
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
|
||||
* Sticks haben oft gar keine (deutsche) TTS-Engine,
|
||||
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
|
||||
* (engine='device') oder wenn kein Server-Audio moeglich ist.
|
||||
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
|
||||
*/
|
||||
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
|
||||
val audio = p.optString("audio_base64")
|
||||
val focus = p.optString("focus").ifBlank { "duck" }
|
||||
return if (audio.isNotBlank())
|
||||
Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
|
||||
else Speaker.speak(appCtx, text, focus)
|
||||
if (audio.isNotBlank())
|
||||
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
|
||||
if (p.optString("engine") == "device")
|
||||
return Speaker.speak(appCtx, text, focus)
|
||||
// Nicht nur abschicken, sondern abwarten bis wirklich Ton kommt —
|
||||
// sonst meldet der Agent Erfolg und der Fernseher bleibt stumm.
|
||||
return runCatching {
|
||||
val mid = voice.requestTts(text)
|
||||
voice.awaitSpeechStart(mid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doVolume(p: JSONObject): JSONObject {
|
||||
|
||||
@@ -65,7 +65,14 @@ object Speaker {
|
||||
var waited = 0
|
||||
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
|
||||
}
|
||||
if (!ttsReady) throw IllegalStateException("Keine Sprachausgabe verfuegbar (TTS-Engine fehlt)")
|
||||
if (!ttsReady) throw IllegalStateException(
|
||||
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
|
||||
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
|
||||
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
|
||||
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
|
||||
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
|
||||
throw IllegalStateException(
|
||||
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
|
||||
AudioFocusHelper.request(ctx, focus, "speak")
|
||||
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
|
||||
override fun onStart(id: String?) {}
|
||||
@@ -73,7 +80,11 @@ object Speaker {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
|
||||
})
|
||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
|
||||
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
|
||||
if (rc != TextToSpeech.SUCCESS) {
|
||||
AudioFocusHelper.abandon(ctx)
|
||||
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ import kotlin.random.Random
|
||||
class VoiceSession(
|
||||
private val appCtx: Context,
|
||||
private val config: AgentConfig,
|
||||
private val send: (type: String, payload: JSONObject) -> Unit,
|
||||
/** Gibt zurueck, ob der Socket die Nachricht angenommen hat (false = ins Leere). */
|
||||
private val send: (type: String, payload: JSONObject) -> Boolean,
|
||||
private val onEvent: (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
@@ -61,6 +62,10 @@ class VoiceSession(
|
||||
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
|
||||
@Volatile private var lastReplyText: String = ""
|
||||
|
||||
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
|
||||
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
|
||||
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
val isRecording: Boolean get() = recording
|
||||
|
||||
// ── Aufnehmen + streamen ────────────────────────────────────────
|
||||
@@ -170,6 +175,56 @@ class VoiceSession(
|
||||
.put("result", JSONObject().put("message", "Aufnahme beendet"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
|
||||
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
|
||||
* und spielt es ab, sobald es hereinkommt.
|
||||
*
|
||||
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
|
||||
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
|
||||
*/
|
||||
fun requestTts(text: String): String {
|
||||
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
|
||||
expectedAudio.add(mid)
|
||||
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
|
||||
if (expectedAudio.size > 20) {
|
||||
synchronized(expectedAudio) {
|
||||
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
|
||||
}
|
||||
}
|
||||
val accepted = send("tts_request", JSONObject().apply {
|
||||
put("text", text)
|
||||
put("messageId", mid)
|
||||
put("voice", "")
|
||||
put("speed", 1.0)
|
||||
})
|
||||
if (!accepted) {
|
||||
expectedAudio.remove(mid)
|
||||
throw IllegalStateException(
|
||||
"Keine Verbindung zum Server — TTS-Anfrage ging nicht raus")
|
||||
}
|
||||
return mid
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet, bis fuer [mid] wirklich der erste Ton kommt.
|
||||
*
|
||||
* Ohne das meldete `host_speak` Erfolg, sobald die Anfrage im Socket lag —
|
||||
* ARIA sagte "erledigt", und der Fernseher blieb still. Hier wird erst
|
||||
* geantwortet, wenn tatsaechlich Audio laeuft, sonst mit klarem Grund.
|
||||
*/
|
||||
fun awaitSpeechStart(mid: String, timeoutMs: Long = 20000) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (playingMessageId == mid) return // Ton laeuft
|
||||
if (!expectedAudio.contains(mid)) return // schon fertig gesprochen
|
||||
try { Thread.sleep(100) } catch (_: InterruptedException) { return }
|
||||
}
|
||||
expectedAudio.remove(mid)
|
||||
throw IllegalStateException(
|
||||
"Server hat kein Audio geliefert (TTS-Dienst erreichbar? Stimme gesetzt?)")
|
||||
}
|
||||
|
||||
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
|
||||
fun ask(text: String): JSONObject {
|
||||
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
|
||||
@@ -206,10 +261,15 @@ class VoiceSession(
|
||||
true
|
||||
}
|
||||
"audio_pcm" -> {
|
||||
// Nur abspielen, wenn es die Antwort auf UNSERE Frage ist und
|
||||
// Sprachausgabe gewuenscht war (sonst redet auch das Handy mit).
|
||||
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
|
||||
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
|
||||
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
|
||||
val mid = p.optString("messageId")
|
||||
if (mid.isNotBlank() && mid == myMessageId && wantSpeech()) feedAudio(p)
|
||||
if (mid.isNotBlank() &&
|
||||
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
|
||||
feedAudio(p)
|
||||
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
|
||||
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds|flagRequestFilterKeyEvents"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:canPerformGestures="true"
|
||||
android:canRequestFilterKeyEvents="true"
|
||||
android:notificationTimeout="100"
|
||||
android:description="@string/accessibility_desc" />
|
||||
|
||||
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.1.2"
|
||||
AGENT_VERSION = "0.0.1.5"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
|
||||
|
||||
@@ -100,9 +100,13 @@ function _parseToolCalls(text) {
|
||||
}
|
||||
|
||||
export function cliResultToOpenai(result, requestId) {
|
||||
const modelName = result.modelUsage
|
||||
? Object.keys(result.modelUsage)[0]
|
||||
: "claude-sonnet-4";
|
||||
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
|
||||
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
|
||||
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
|
||||
// zustande kam (z.B. unbekanntes Modell).
|
||||
const usage = result.modelUsage || {};
|
||||
const usedKey = Object.keys(usage)[0];
|
||||
const modelName = (usedKey && (usage[usedKey]?.canonicalModel || usedKey)) || "";
|
||||
|
||||
const rawText = result.result || "";
|
||||
const { tool_calls, rest } = _parseToolCalls(rawText);
|
||||
@@ -134,13 +138,32 @@ export function cliResultToOpenai(result, requestId) {
|
||||
total_tokens:
|
||||
(result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
|
||||
},
|
||||
// ARIA-Zusatzfelder (ausserhalb des OpenAI-Schemas, Clients ignorieren
|
||||
// sie). Damit kann die Diagnostic pruefen, ob ein Modell wirklich
|
||||
// gelaufen ist: bei einem unbekannten Namen kommt die CLI gar nicht bis
|
||||
// zum Call, dann ist modelUsage leer und is_error gesetzt. Ohne das
|
||||
// koennte man einen Fehlschlag nicht von einer Antwort unterscheiden —
|
||||
// die Fehlermeldung steht naemlich einfach im content.
|
||||
aria_model: modelName,
|
||||
aria_error: result.is_error === true,
|
||||
aria_context_window: (usedKey && usage[usedKey]?.contextWindow) || 0,
|
||||
aria_max_output: (usedKey && usage[usedKey]?.maxOutputTokens) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Modellname fuer die Antwort.
|
||||
*
|
||||
* Hier wurde der Name FRUEHER umgeschrieben: alles mit "opus" drin wurde zu
|
||||
* "claude-opus-4", Sonnet zu "claude-sonnet-4" usw. Damit log das Feld — die
|
||||
* CLI liefert in modelUsage den tatsaechlich benutzten Namen (z.B.
|
||||
* "claude-opus-5-5"), und genau der wurde weggeworfen. Im OpenAI-Protokoll
|
||||
* soll `model` das Modell nennen, das geantwortet HAT; ein erfundener Name
|
||||
* macht jede Ueberpruefung unmoeglich ("welches Modell lief gerade?").
|
||||
*
|
||||
* Jetzt bleibt der echte Name stehen; nur wenn gar keiner da ist, gibt es
|
||||
* einen neutralen Platzhalter.
|
||||
*/
|
||||
function normalizeModelName(model) {
|
||||
const m = model || "claude-sonnet-4";
|
||||
if (m.includes("opus")) return "claude-opus-4";
|
||||
if (m.includes("sonnet")) return "claude-sonnet-4";
|
||||
if (m.includes("haiku")) return "claude-haiku-4";
|
||||
return m;
|
||||
return model || "unknown";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
*/
|
||||
import { spawn } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||
export class ClaudeSubprocess extends EventEmitter {
|
||||
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
timeoutId = null;
|
||||
isKilled = false;
|
||||
_stdinClosed = false;
|
||||
_systemPromptFile = null;
|
||||
_systemPromptDir = null;
|
||||
/**
|
||||
* Start the Claude CLI subprocess with the given prompt
|
||||
*/
|
||||
@@ -51,6 +56,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
// Handle spawn errors (e.g., claude not found)
|
||||
this.process.on("error", (err) => {
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
if (err.message.includes("ENOENT")) {
|
||||
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
||||
}
|
||||
@@ -85,6 +91,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
this.process.on("close", (code) => {
|
||||
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
// Process any remaining buffer
|
||||
if (this.buffer.trim()) {
|
||||
this.processBuffer();
|
||||
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
}
|
||||
catch (err) {
|
||||
this.clearTimeout();
|
||||
this._cleanupSystemPrompt();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
|
||||
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||
"--model",
|
||||
options.model, // Model alias (opus/sonnet/haiku)
|
||||
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
|
||||
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
|
||||
];
|
||||
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
|
||||
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
|
||||
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
|
||||
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
|
||||
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
|
||||
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
|
||||
// praktisch keine Groessengrenze mehr.
|
||||
this._systemPromptFile = null;
|
||||
const sysPrompt = options.systemPrompt || "";
|
||||
if (sysPrompt) {
|
||||
try {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
|
||||
const file = path.join(dir, "system-prompt.txt");
|
||||
fs.writeFileSync(file, sysPrompt, "utf-8");
|
||||
this._systemPromptFile = file;
|
||||
this._systemPromptDir = dir;
|
||||
args.push("--system-prompt-file", file);
|
||||
}
|
||||
catch (err) {
|
||||
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
|
||||
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
|
||||
args.push("--system-prompt", sysPrompt);
|
||||
}
|
||||
}
|
||||
if (options.sessionId) {
|
||||
args.push("--session-id", options.sessionId);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
/** Temporaere System-Prompt-Datei wieder entfernen. */
|
||||
_cleanupSystemPrompt() {
|
||||
try {
|
||||
if (this._systemPromptFile)
|
||||
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
|
||||
}
|
||||
catch (_) { }
|
||||
this._systemPromptFile = null;
|
||||
this._systemPromptDir = null;
|
||||
}
|
||||
/**
|
||||
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||
|
||||
@@ -15,29 +15,47 @@
|
||||
* (siehe docker-compose.yml proxy-Block).
|
||||
*/
|
||||
|
||||
// NUR echte Tier-Aliase. Die CLI loest sie selbst auf die aktuelle Version des
|
||||
// Tiers auf.
|
||||
//
|
||||
// Hier standen frueher auch volle Versionen (claude-opus-5 → "opus",
|
||||
// claude-fable-5 → "fable", ...). Das war falsch: wer bewusst eine BESTIMMTE
|
||||
// Version waehlte, bekam still das Tier-Default — also unter Umstaenden ein
|
||||
// ganz anderes Modell als ausgewaehlt. Volle Namen gehen jetzt unveraendert an
|
||||
// die CLI (siehe extractModel), die versteht sie.
|
||||
const MODEL_MAP = {
|
||||
"claude-opus-4": "opus",
|
||||
"claude-sonnet-4": "sonnet",
|
||||
"claude-haiku-4": "haiku",
|
||||
"claude-code-cli/claude-opus-4": "opus",
|
||||
"claude-code-cli/claude-sonnet-4": "sonnet",
|
||||
"claude-code-cli/claude-haiku-4": "haiku",
|
||||
"opus": "opus",
|
||||
"sonnet": "sonnet",
|
||||
"haiku": "haiku",
|
||||
"fable": "fable",
|
||||
"claude-fable-5": "fable",
|
||||
"claude-code-cli/opus": "opus",
|
||||
"claude-code-cli/sonnet": "sonnet",
|
||||
"claude-code-cli/haiku": "haiku",
|
||||
"claude-code-cli/fable": "fable",
|
||||
// Volle aktuelle IDs (falls die App/Diagnostic sie mal direkt setzt)
|
||||
"claude-opus-5": "opus",
|
||||
"claude-sonnet-5": "sonnet",
|
||||
"claude-haiku-4-5": "haiku",
|
||||
};
|
||||
|
||||
// Echte Claude-Modellnamen: claude-<familie>-<zahlen/suffixe>.
|
||||
// Die CLI nimmt laut `claude --help` "an alias for the latest model (e.g.
|
||||
// 'fable', 'opus', or 'sonnet') or a model's full name" — exakte Versionen
|
||||
// darf man also direkt durchreichen.
|
||||
const FULL_MODEL_RE = /^claude-[a-z]+-[0-9][0-9a-z.-]*$/;
|
||||
|
||||
export function extractModel(model) {
|
||||
if (MODEL_MAP[model]) return MODEL_MAP[model];
|
||||
const stripped = (model || "").replace(/^claude-code-cli\//, "");
|
||||
if (MODEL_MAP[stripped]) return MODEL_MAP[stripped];
|
||||
// Exakte Version durchreichen statt sie auf einen Tier einzuschmelzen.
|
||||
//
|
||||
// Vorher landete JEDER unbekannte Name still auf "opus". Damit war eine
|
||||
// neuere Version faktisch nicht waehlbar: wer "claude-opus-5-5" setzte,
|
||||
// bekam ohne jede Meldung das Tier-Default — die Einstellung sah gesetzt
|
||||
// aus und tat nichts. Kennt die CLI den Namen nicht, sagt SIE das deutlich
|
||||
// ("isn't described by this version's model catalog"), und das ist die
|
||||
// ehrlichere Antwort als ein stilles Downgrade.
|
||||
if (FULL_MODEL_RE.test(stripped)) return stripped;
|
||||
if (stripped) {
|
||||
console.error(`[aria-model] Unbekannter Modellname ${JSON.stringify(stripped)} — nutze 'opus'`);
|
||||
}
|
||||
return "opus";
|
||||
}
|
||||
|
||||
|
||||
+131
-6
@@ -552,19 +552,144 @@ function _loadModels() {
|
||||
return DEFAULT_MODELS;
|
||||
}
|
||||
|
||||
export function handleModels(_req, res) {
|
||||
// ── Echte Modell-Liste von Anthropic ────────────────────────────────
|
||||
//
|
||||
// Anthropic veroeffentlicht einen signierten Modell-Katalog, aus dem auch
|
||||
// Claude Code selbst seinen Modell-Auswaehler fuellt. Oeffentlich erreichbar,
|
||||
// KEIN API-Key noetig (die Models-API /v1/models braucht einen; mit dem
|
||||
// Max-Abo gibt es keinen).
|
||||
//
|
||||
// Warum ueberhaupt: die handgepflegte Liste hier veraltete garantiert. Sie
|
||||
// kannte Opus 5 — waehrend im Katalog laengst Opus 5.5, Sonnet 5.5 und
|
||||
// Fable 5.1 standen. Ein "↻ Aktualisieren"-Knopf, der nur eine lokale Datei
|
||||
// neu liest, aktualisiert nichts.
|
||||
//
|
||||
// Struktur: surfaces.<surface>.model_selector_config[].models[]
|
||||
// = { id, name, short_name, description, section }
|
||||
// 'cc' ist die Claude-Code-Oberflaeche — genau die, durch die ARIA laeuft.
|
||||
// section 'main' = die vorgeschlagenen, 'overflow' = aeltere/weitere.
|
||||
const CATALOG_URL = process.env.ARIA_MODEL_CATALOG_URL
|
||||
|| "https://downloads.claude.ai/model-catalog/v1/catalog.json";
|
||||
const CATALOG_CACHE_FILE = "/shared/config/model_catalog_cache.json";
|
||||
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h — der Katalog gilt eine Woche
|
||||
const CATALOG_SURFACE = "cc";
|
||||
|
||||
let _catalogMem = null; // { at, models }
|
||||
|
||||
function _parseCatalog(doc) {
|
||||
const out = [];
|
||||
const surfaces = (doc && doc.surfaces && doc.surfaces[CATALOG_SURFACE]) || null;
|
||||
const configs = surfaces && surfaces.model_selector_config;
|
||||
if (!Array.isArray(configs)) return out;
|
||||
for (const cfg of configs) {
|
||||
for (const m of (cfg.models || [])) {
|
||||
if (!m || typeof m.id !== "string") continue;
|
||||
out.push({
|
||||
id: m.id,
|
||||
tier: m.short_name ? String(m.short_name).toLowerCase() : m.id,
|
||||
display_name: m.name || m.id,
|
||||
description: m.description || "",
|
||||
section: m.section || "main",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function _readCatalogCache() {
|
||||
try {
|
||||
const c = JSON.parse(fs.readFileSync(CATALOG_CACHE_FILE, "utf-8"));
|
||||
if (Array.isArray(c.models) && c.models.length) return c;
|
||||
} catch (_) { /* kein Cache */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Katalog-Modelle. `force` umgeht den TTL (Diagnostic-Knopf).
|
||||
* Schlaegt der Abruf fehl, gilt der Cache — auch ein abgelaufener, denn eine
|
||||
* etwas alte echte Liste ist besser als gar keine.
|
||||
*/
|
||||
async function _catalogModels(force) {
|
||||
const now = Date.now();
|
||||
if (!force && _catalogMem && (now - _catalogMem.at) < CATALOG_TTL_MS) {
|
||||
return _catalogMem.models;
|
||||
}
|
||||
if (!force) {
|
||||
const cached = _readCatalogCache();
|
||||
if (cached && (now - (cached.at || 0)) < CATALOG_TTL_MS) {
|
||||
_catalogMem = { at: cached.at, models: cached.models };
|
||||
return cached.models;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ctl = AbortSignal.timeout ? AbortSignal.timeout(10000) : undefined;
|
||||
const r = await fetch(CATALOG_URL, { signal: ctl });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const doc = await r.json();
|
||||
const models = _parseCatalog(doc);
|
||||
if (!models.length) throw new Error("Katalog enthaelt keine Modelle fuer Surface " + CATALOG_SURFACE);
|
||||
_catalogMem = { at: now, models };
|
||||
try {
|
||||
fs.mkdirSync("/shared/config", { recursive: true });
|
||||
fs.writeFileSync(CATALOG_CACHE_FILE, JSON.stringify({
|
||||
at: now, version: doc.version, expires_at: doc.expires_at, models,
|
||||
}, null, 2));
|
||||
} catch (e) {
|
||||
console.error("[aria-models] Katalog-Cache schreiben fehlgeschlagen:", e && e.message);
|
||||
}
|
||||
console.error(`[aria-models] Katalog geladen: ${models.length} Modelle (Version ${doc.version})`);
|
||||
return models;
|
||||
} catch (e) {
|
||||
console.error("[aria-models] Katalog-Abruf fehlgeschlagen:", e && e.message);
|
||||
const cached = _readCatalogCache();
|
||||
if (cached) {
|
||||
console.error("[aria-models] nutze Cache vom " + new Date(cached.at || 0).toISOString());
|
||||
_catalogMem = { at: cached.at, models: cached.models };
|
||||
return cached.models;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleModels(req, res) {
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const models = _loadModels();
|
||||
const force = !!(req && req.query && (req.query.refresh === "1" || req.query.refresh === "true"));
|
||||
|
||||
// 1) Tier-Aliase zuerst: sie zeigen IMMER auf die aktuelle Version ihres
|
||||
// Tiers, ganz ohne Pflege. Fuer den Alltag die richtige Wahl.
|
||||
const tiers = _loadModels().map(m => ({
|
||||
id: m.id,
|
||||
tier: m.tier || m.id,
|
||||
display_name: m.display_name || m.id,
|
||||
description: m.description || "",
|
||||
section: "tier",
|
||||
}));
|
||||
|
||||
// 2) Danach die exakten Versionen aus dem Anthropic-Katalog.
|
||||
const catalog = await _catalogModels(force);
|
||||
const seen = new Set(tiers.map(t => t.id));
|
||||
const exact = catalog.filter(m => !seen.has(m.id)).map(m => ({
|
||||
id: m.id,
|
||||
tier: m.tier,
|
||||
display_name: m.display_name
|
||||
+ (m.section === "main" ? "" : " (ältere Version)"),
|
||||
description: m.description
|
||||
|| (m.section === "main" ? "" : "Aeltere Version — nur waehlen wenn gewollt."),
|
||||
section: m.section,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
object: "list",
|
||||
data: models.map(m => ({
|
||||
catalog_ok: catalog.length > 0,
|
||||
data: [...tiers, ...exact].map(m => ({
|
||||
id: m.id,
|
||||
object: "model",
|
||||
owned_by: "anthropic",
|
||||
created,
|
||||
tier: m.tier || m.id,
|
||||
display_name: m.display_name || m.id,
|
||||
description: m.description || "",
|
||||
tier: m.tier,
|
||||
display_name: m.display_name,
|
||||
description: m.description,
|
||||
section: m.section,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
+126
@@ -84,6 +84,14 @@ const ALLOWED_TYPES = new Set([
|
||||
// (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt.
|
||||
"sat_creds_set", "sat_creds_delete", "sat_creds_list",
|
||||
"sat_creds_result", "sat_creds_list_result",
|
||||
// Port-Tunnel: ARIA erreicht ueber den Satelliten einen beliebigen Port im
|
||||
// fremden LAN (FreeCAD-MCP, Web-App ...). Die Bridge macht daraus einen
|
||||
// lokalen Port. sat_tunnel_data laeuft in BEIDE Richtungen — unterschieden
|
||||
// wird ueber die tunnelId. Plus sat_name_* fuer vergebene Geraetenamen,
|
||||
// damit ARIA "duffy-desktop" sagen kann statt einer IP.
|
||||
"sat_tunnel_open", "sat_tunnel_opened", "sat_tunnel_data",
|
||||
"sat_tunnel_close", "sat_tunnel_closed",
|
||||
"sat_name_set", "sat_name_result",
|
||||
// Host-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit
|
||||
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
|
||||
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
|
||||
@@ -93,6 +101,15 @@ const ALLOWED_TYPES = new Set([
|
||||
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
|
||||
// der RVS sie still an der Allow-List und die Historie bleibt leer.
|
||||
"agent_history", "agent_history_query", "agent_history_list",
|
||||
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
|
||||
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
|
||||
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
|
||||
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
|
||||
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
|
||||
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
|
||||
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
|
||||
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
|
||||
"chat_ack",
|
||||
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
|
||||
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
||||
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
||||
@@ -104,6 +121,9 @@ const ALLOWED_TYPES = new Set([
|
||||
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
|
||||
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
|
||||
"worker_hello", "worker_ping", "worker_update", "worker_list",
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
|
||||
"node_stats_history_request", "node_stats_history",
|
||||
"node_stats_reset", "node_stats_reset_done",
|
||||
@@ -112,6 +132,86 @@ const ALLOWED_TYPES = new Set([
|
||||
// Token-Raum: token -> { clients: Set<ws> }
|
||||
const rooms = new Map();
|
||||
|
||||
// ── Punkt-zu-Punkt-Antworten ────────────────────────────────────────
|
||||
//
|
||||
// Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum
|
||||
// jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als
|
||||
// base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die
|
||||
// STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und
|
||||
// die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht
|
||||
// kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz,
|
||||
// aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt.
|
||||
//
|
||||
// Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit
|
||||
// derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie
|
||||
// bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss,
|
||||
// dessen Anfrage der RVS nie gesehen hat).
|
||||
const TARGETED = new Map([
|
||||
["file_request", "file_response"],
|
||||
["file_zip_request", "file_zip_response"],
|
||||
["file_version_download_request", "file_version_download_response"],
|
||||
["brain_request", "brain_response"],
|
||||
]);
|
||||
const TARGETED_REPLIES = new Set(TARGETED.values());
|
||||
|
||||
// `antwortTyp \0 requestId` -> { clients: Set<ws>, at }
|
||||
// Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die
|
||||
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
|
||||
const pendingReplies = new Map();
|
||||
const PENDING_TTL_MS = 180_000;
|
||||
|
||||
function pendingKey(replyType, requestId) {
|
||||
return `${replyType} | ||||