Compare commits

...
17 Commits
Author SHA1 Message Date
duffyduck 517a8ea942 release(agent): bump to 0.0.1.5 2026-10-03 12:40:58 +02:00
duffyduckandClaude Opus 5 4589f4abc3 feat(android-agent): Einblendung geht weg, wenn Stefan etwas tut — statt nach fester Zeit
Eine feste Anzeigedauer ist immer geraten: mal zu kurz zum Lesen, mal steht sie
laengst im Weg. `seconds=0` liess die Einblendung bisher einfach stehen.

Jetzt heisst seconds=0: bleibt, bis der Nutzer etwas tut.

- Fernbedienung: der Accessibility-Dienst bekommt onKeyEvent und raeumt die
  Einblendung weg. Er gibt die Taste mit `return false` WEITER an den Film —
  es geht also kein Tastendruck verloren und der Player bleibt bedienbar.
  Dafuer brauchte der Dienst canRequestFilterKeyEvents + flagRequestFilterKeyEvents.
- Touch: FLAG_WATCH_OUTSIDE_TOUCH meldet eine Beruehrung ausserhalb. Die geht
  dank FLAG_NOT_TOUCHABLE gleichzeitig an die App darunter durch.
- 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. Genau das soll
sie nicht.

host_notify beschreibt seconds=0 jetzt als den richtigen Wert fuer alles zum
Lesen (Wetter, Listen, Zahlen) und die feste Dauer fuer kurze Hinweise.

APK baut durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 12:29:14 +02:00
duffyduckandClaude Opus 5 e67659b78a feat(diagnostic): warnt, wenn der Proxy mit veralteten Patches laeuft
Der Proxy kopiert proxy-patches/*.js beim START ueber den npm-Code. Wer nach
einem git pull nur `restart` macht — oder neu startet, BEVOR der Pull durch ist
— laeuft still mit der alten Fassung weiter. Nichts deutet darauf hin: der
Container ist "Up", die Oberflaeche sieht normal aus, die Aenderung wirkt
einfach nicht.

Das hat heute zweimal Zeit gekostet: einmal wirkte ein Proxy-Fix nicht, einmal
blieb die Modell-Liste alt, obwohl der Code laengst gepullt war (Proxy
gestartet 08:51, Patches gepullt 09:28).

Die Einstellungen vergleichen jetzt die Zeitstempel der Quelldateien mit den
eingespielten und zeigen einen Hinweis samt fertigem Befehl — inklusive der
Angabe, welche Datei wann geaendert und wann eingespielt wurde. Toleranz 5s,
weil Quelle und Kopie beim Start kurz nacheinander entstehen. Ein Fehler beim
Pruefen loest bewusst KEINEN Alarm aus, sonst stuende dort eine Warnung, die
nichts ueber die Patches aussagt.

Dazu: der Datenschutz-Block sagt jetzt ausdruecklich, dass KEIN Neustart noetig
ist — die CLI liest ~/.claude/settings.json bei jedem Aufruf neu. Nur ein
gerade laufender Turn nutzt noch die alte Einstellung.

4 Pruefungen gegen den echten Container, in beide Richtungen: schweigt nach
frischem Start, erkennt eine veraltete Kopie samt Zeitstempeln, schweigt nach
Wiederherstellung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 11:38:04 +02:00
duffyduck 988b074478 release(agent): bump to 0.0.1.4 2026-10-03 11:27:12 +02:00
duffyduckandClaude Opus 5 d5eea920a6 feat(diagnostic): Datenschutz-Schalter von Claude Code pruefen und setzen
Drei Kanaele, ueber die Claude Code zusaetzlich zur eigentlichen Anfrage Daten
verschickt — bisher nur per Hand in der docker-compose.yml abschaltbar, und
niemand konnte sehen, ob sie an sind:

  DISABLE_ERROR_REPORTING   Stacktraces an einen Fehler-Dienst. Bei Pro/Max ab
                            CLI 2.1.198 standardmaessig AN.
  DISABLE_TELEMETRY         Nutzungs-Metriken.
  DISABLE_FEEDBACK_COMMAND  /feedback sendet sonst die ganze Unterhaltung
                            inkl. Code — 5 Jahre Aufbewahrung.

Gesetzt wird in ~/.claude/settings.json unter "env". Die Datei ist in den
Proxy-Container gemountet, die Diagnostic erreicht sie ueber den vorhandenen
dockerExec — also kein Anfassen der docker-compose.yml und KEIN Neustart: die
CLI liest die Datei pro Aufruf.

Geprueft wird beides: was in settings.json steht UND was im Prozess wirklich
ankommt. Eine echte Container-Umgebungsvariable gewinnt gegen settings.json —
so ein Eintrag wird darum als "per docker-compose gesetzt" angezeigt und der
Haken gesperrt, statt eine Einstellung anzubieten, die nichts tut.

Geschrieben wird atomar (temp + mv) und nur der env-Block angefasst; bestehende
Einstellungen (tui, theme) bleiben. Sind alle drei abgewaehlt, verschwindet der
env-Block wieder ganz.

Der Hinweis im Panel sagt ausdruecklich, dass das NICHT die Trainings-
Einstellung ist — die steht im Anthropic-Konto und laesst sich hier nicht
umstellen. Link dorthin ist daneben.

12 Pruefungen gegen den echten Proxy-Container: lesen, setzen, Teilmengen,
JSON-Integritaet, Erhalt bestehender Werte, CLI laeuft damit, Abwaehlen raeumt
auf, Ausgangszustand wiederhergestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 11:21:47 +02:00
duffyduckandClaude Opus 5 2c60a19ed6 feat(oauth): ARIA bekommt Zugangstokens nicht mehr zu sehen
oauth_get_token hat den access_token im Klartext als Tool-Ergebnis
zurueckgegeben. Tool-Ergebnisse sind Teil der Unterhaltung — der Token ging
damit an Anthropic und lag je nach Konto-Einstellung 30 Tage oder 5 Jahre dort.

Neu: das Brain fuehrt den API-Aufruf SELBST aus.

- `oauth_call(service, url, method, body)` haengt die Authorization an,
  schickt den Request und gibt nur die Antwort zurueck. Der Token verlaesst
  den Brain-Prozess nicht.
- `oauth_get_token` sagt nur noch, OB authentifiziert ist und wie lange noch.
  Refresh laeuft weiter automatisch.
- Skills bleiben unveraendert: die holen den Token serverseitig ueber
  {BRAIN_INTERNAL_URL}/oauth/<service>/token — der laeuft nie durch ein
  Gespraech.

Host-Sperre als Sicherheitskern: die URL kommt vom Modell, also koennte ein
Aufruf den Token sonst an einen beliebigen Server schicken. Erlaubt sind nur
Hosts, die zum Provider gehoeren (aus auth_url/token_url, plus optionale
api_hosts). Verglichen wird auf den letzten zwei Labels, NICHT per endswith —
"boesespotify.com" endet sonst auch auf "spotify.com". Dazu: nur https (sonst
ginge der Token im Klartext ueber die Leitung) und nur die ueblichen Methoden.

19 Pruefungen, darunter die Angriffsvarianten: boesespotify.com,
spotify.com.angreifer.de, accounts.spotify.com.evil.io, spotify.co, leerer
Host — alle blockiert; api.spotify.com und abschliessender Punkt erlaubt.

System-Prompt und seed_rules nachgezogen, sonst erklaert ARIA sich das alte
Verhalten selbst. Beide sagen jetzt zusaetzlich ausdruecklich: Secrets gehoeren
nie in den Chat, und ein Secret nie in einer Antwort wiederholen. Genau so ist
hier naemlich ein client_secret abgeflossen — nicht ueber ein Tool, sondern
weil es eingetippt und dann zurueckzitiert wurde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 11:16:41 +02:00
duffyduckandClaude Opus 5 752e2abc5f feat(models): Test-Knopf — zeigt, welches Modell wirklich geantwortet hat
Bisher liess sich nicht nachsehen, ob eine Modell-Einstellung ueberhaupt
greift. Der Proxy hat die Information sogar aktiv weggeworfen:
normalizeModelName() nahm den echten Namen aus modelUsage (z.B.
"claude-opus-5-5") und schrieb ihn auf "claude-opus-4" um. Das `model`-Feld
der Antwort log also — und damit war jede Ueberpruefung unmoeglich.

- normalizeModelName gibt den echten Namen durch. Im OpenAI-Protokoll soll
  `model` das Modell nennen, das geantwortet HAT; ein erfundener Name ist
  schlechter als keiner.
- Zusatzfelder aria_model / aria_error / aria_context_window / aria_max_output
  aus modelUsage.canonicalModel. Noetig, weil die CLI bei einem unbekannten
  Modell gar nicht bis zum Call kommt und ihre Begruendung einfach in den
  Antworttext legt — ohne Flag waere ein Fehlschlag nicht von einer Antwort
  zu unterscheiden.
- Knopf "🔬 Testen" neben Dropdown und Freitextfeld. Er schickt eine echte
  Mini-Anfrage durch den GANZEN Weg (Diagnostic → Proxy → extractModel →
  claude CLI → Anthropic).

Ausdruecklich NICHT gemacht: das Modell nach seiner eigenen Version fragen.
Darauf ist kein Verlass — Modelle kennen ihren Versionsstring oft nicht und
raten. Und den Dropdown-Wert zurueckzugeben waere sinnlos. Die Wahrheit kommt
aus modelUsage der CLI.

Vier unterscheidbare Ergebnisse:
  Tier-Alias      gruen  — zeigt die aufgeloeste Version ("opus" → Opus 5.5)
  exakte Version  gruen  — angefragt == geantwortet
  nicht erkannt   GELB   — der Proxy hat still "opus" eingesetzt; warnt
                           ausdruecklich davor, die ID so zu setzen
  ungueltig       ROT    — CLI-Begruendung im Klartext

Der gelbe Fall war wichtig: ein Name, der nicht wie ein Claude-Modell aussieht,
faellt auf "opus" zurueck und haette sonst als Erfolg gegolten — man setzt dann
eine ID, die nie benutzt wird, und merkt es nie.

Geprueft gegen ECHTE CLI-Ausgaben aus dem Proxy-Container (9 Faelle: Alias,
exakte Version, Tippfehler) plus 7 Faelle fuer die Anzeige-Logik. Nebenbefund:
`--model opus` loest derzeit auf claude-opus-5-5 auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:54:22 +02:00
duffyduckandClaude Opus 5 09d06011d5 fix(models): Modell-Liste kommt jetzt von Anthropic — und feste Versionen greifen wirklich
Zwei Fehler. Der zweite war der schlimmere.

1) Die Liste war handgepflegt und damit garantiert veraltet.
   Sie kannte "Opus (aktuell: Opus 5)" — waehrend es laengst Opus 5.5,
   Sonnet 5.5 und Fable 5.1 gab. Der Knopf "↻ Aktualisieren" las nur eine
   lokale Datei neu und aktualisierte damit gar nichts.

   Jetzt kommt die Liste aus Anthropics eigenem Modell-Katalog
   (downloads.claude.ai/model-catalog/v1/catalog.json, Surface 'cc') —
   dieselbe Quelle, aus der sich Claude Code selbst bedient. Oeffentlich,
   ohne API-Key (die Models-API braucht einen; mit dem Max-Abo gibt es keinen).
   6h-Cache plus Datei-Cache, der auch offline und bei abgelaufenem TTL
   einspringt: eine etwas alte echte Liste ist besser als keine.

   Die Tier-Aliase (opus/sonnet/haiku/fable) bleiben vorne und bleiben die
   Empfehlung — sie folgen automatisch der neuesten Version. Darunter die
   exakten Versionen, aeltere ausdruecklich als solche markiert.

2) Eine feste Version liess sich faktisch nicht waehlen.
   extractModel() hatte eine MODEL_MAP und fiel fuer alles Unbekannte STILL
   auf "opus" zurueck. Wer "claude-opus-5-5" setzte, bekam ohne jede Meldung
   das Tier-Default. Schlimmer noch: claude-opus-5 und claude-fable-5 standen
   selbst in der Map und wurden auf ihr Tier eingeschmolzen — eine bewusst
   gewaehlte aeltere Version wurde also durch die neueste ersetzt.

   Volle Modellnamen gehen jetzt unveraendert an die CLI. Die versteht sie
   ("--model: an alias for the latest model or a model's full name") und
   meldet sich selbst klar, wenn sie einen Namen nicht kennt — ehrlicher als
   ein stilles Downgrade. Nur echter Muell faellt noch auf "opus", und das
   wird geloggt.

Nachgewiesen gegen den LIVE-Katalog und die echte CLI im Proxy-Container:
20 Pruefungen gruen, u.a. dass alle 12 gelieferten exakten Versionen den Proxy
unveraendert passieren. `claude --print --model claude-opus-5-5` antwortet.
Stefans aktive Einstellung ist der Alias "opus" — am laufenden Betrieb aendert
sich dadurch nichts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:47:34 +02:00
duffyduckandClaude Opus 5 4c0d8ce3fe feat(agent): Bild mit Text einblenden — host_notify kann jetzt image_path
Der Android-Agent konnte schon immer ein Bild MIT Text einblenden
(OverlayView.show nimmt ein imageB64), aber ARIA kam nicht dran: in der
Parameter-Liste von host_notify fehlte das Feld einfach. Die Faehigkeit war
gebaut und unerreichbar.

ARIA gibt jetzt image_path an (ein Pfad unter /shared/uploads/, damit arbeitet
sie sowieso) und das Brain wandelt die Datei nach base64 — statt ARIA
megabyteweise base64 durch den Tool-Call schieben zu lassen. Pfad-Praefix wird
geprueft, und ueber 4 MB wird abgelehnt: fuer eine Einblendung auf einem
Fernseher ist mehr sinnlos und blaeht die WS-Nachricht auf.

Nur Android-Agenten koennen das; die Beschreibung sagt das ausdruecklich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:34:04 +02:00
duffyduckandClaude Opus 5 734868efab feat(diagnostic): Anhang-Limit einstellbar + Datum/Uhrzeit bei Gedanken
Zwei Dinge, die bisher nur im Code bzw. nur in der DB standen.

Anhang-Limit (Einstellungen → System & Wartung):
- Das Limit war eine Env-Variable mit 20 MB Default — zum Anheben musste man
  die .env editieren und das Brain neu starten. Jetzt steht es in runtime.json
  und das Brain liest es bei JEDEM Upload neu: Aenderung greift sofort.
- Reihenfolge: runtime.json > ENV > Default. Anders als beim OpenAI-Endpunkt
  gewinnt hier NICHT die .env — dort erzwingt ein Docker-Port-Mapping das,
  hier gibt es keinen solchen Zwang, und eine Einstellung die nichts tut waere
  schlimmer als keine.
- Default von 20 auf 64 MB. Genau so viel holt die App auch auf ausdruecklichen
  Tipp noch herunter (TAP_DL_MAX_BYTES); darueber warnt die UI, verbietet es
  aber nicht — der Anhang liegt dann sauber auf dem Server, ist mobil nur nicht
  mehr ladbar.
- Neuer Endpunkt GET /attachments/limit. Das fest eingetippte "max 20 MB pro
  Datei" im HTML ist weg — es stimmte nach jeder Aenderung nicht mehr.
- Die Fehlermeldung nennt jetzt MB statt Bytes und sagt, wo die Einstellung ist.

Datum/Uhrzeit bei Gedanken:
- created_at/updated_at lagen schon immer in der DB und wurden ausgeliefert —
  nur nie angezeigt. Jede Zeile zeigt jetzt "angelegt <Zeit>" und, falls
  abweichend, "geaendert <Zeit>" plus ein relatives "(vor 3 Std)". Damit sieht
  man auf den ersten Blick, was der letzte Eintrag war.
- Sortier-Dropdown: nach Typ gruppiert (wie bisher) oder nach Anlage/Aenderung
  auf- und absteigend. Bei Zeit-Sortierung wird die Liste flach dargestellt —
  man will sehen was zuletzt passiert ist, nicht in welche Schublade es gehoert.
- Zeitraum-Filter (🕒) mit von/bis, umschaltbar zwischen angelegt und geaendert,
  plus Schnellwahl Heute / 7 / 30 Tage. Gilt auch fuer Such-Treffer, damit
  "suche X, aber nur letzte Woche" geht. Zuklappen schaltet ihn aus, und das
  ✕-Zuruecksetzen raeumt ihn mit auf (sonst filtert er unsichtbar weiter).

Beides gegen den echten Code getestet: 7 Faelle fuers Limit (inkl. kaputter
runtime.json und einem 50-MB-PDF an beiden Grenzen), 10 fuer Sortierung und
Zeitraum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:33:54 +02:00
duffyduckandClaude Opus 5 7b46881677 docs: README auf den aktuellen Stand — Android-Agent, Jellyfin, OpenAI-Endpunkt, Phase C
Die README war auf dem Stand vom 24.09. Seitdem sind ~60 Commits dazugekommen,
von denen ganze Baustellen gar nicht auftauchten.

Neu dokumentiert:
- Android-Agent 📱📺 (eigener Abschnitt): eine APK fuer Handy und Fire TV —
  sehen, steuern, ausgeben (Overlay + ARIAs echte Stimme), Audio-Fokus,
  Sprachdialog, Turn-Ownership im Broadcast-Relay, fester Signaturschluessel,
  Ausrollen per Satellit via ADB inkl. Fern-Provisionierung
- GUI-Steuerung der Desktop-Host-Agenten (capability-gated)
- Jellyfin-Integration (mehrere Server, API statt Klick-Weg)
- OpenAI-Endpunkt fuers lokale LLM + openhands-Profil, inkl. der Regel
  ".env gewinnt immer -> Diagnostic-Felder nur lesend"
- Agent-Historie und Auslastungs-Monitor in der Diagnostic
- App: Teilen-Ziel, Sprachnachricht zu Anhaengen, Zwischenruf per Sprache
- Trigger: Projekt-Kontext + ehrlicher Zustand in trigger_list
- Roadmap "Phase C" samt der fuenfteiligen Ursachenkette hinter den verlorenen
  Sprachnachrichten — jede Ursache sah fuer sich plausibel aus, das ist
  dokumentationswuerdig

Korrigiert (waren schlicht falsch):
- "Bildanalyse eingeschraenkt, ARIA sieht den Dateipfad, nicht das Bild" —
  widersprach der eigenen Roadmap; Vision laeuft
- "Grosse Dateien >5MB koennen WebSocket-Limits ueberschreiten" — die echten
  Grenzen sind jetzt 1500 MB Transport, 25 MB Auto-Download, 64 MB auf Tipp
- Audio-Format: Live-PCM-Streaming ist der Normalweg, AAC/MP4 nur noch Fallback

Neu als Limitierung benannt, weil heute gefragt: die Sprachaufnahme ist NICHT
wiederaufsetzbar — ein Verbindungsabbruch mitten in der Aufnahme verliert das
Stueck, es wird nicht gepuffert. Gleiches gilt fuer die TTS-Richtung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:21:50 +02:00
duffyduckandClaude Opus 5 cb89ebcd10 fix(stt): lange Sprachnachrichten brachen mitten im Satz ab
Der Endpointer hat seine EIGENE Rechenzeit als Stille gezaehlt.

Der Tick-Loop laeuft alle 200ms und ist die einzige Stelle, die akustische
Sprach-Energie misst (last_voice_at). Er hat aber auf die Zwischen-
Transkription gewartet — und die laeuft jedes Mal ueber den KOMPLETTEN Buffer.
Bei 10s Audio harmlos, bei zwei Minuten dauert sie mehrere Sekunden. In dieser
Zeit tickte nichts, danach war `now - last_voice_at` um die ganze Rechenzeit
groesser als endpoint_ms (5s) — Endpoint mitten im Satz, obwohl durchgehend
gesprochen wurde. Deshalb traf es genau die LANGEN Nachrichten, und zwar immer
spaeter werdend je laenger die Aufnahme.

Nachgewiesen: die Nachricht vom 02.10. 21:28 (1231 Zeichen) war die einzige mit
reason=endpoint statt stream_end und endet mitten im Satz ("...weil ich
weiss..."). Alle vollstaendigen Nachrichten im Thread haben reason=stream_end,
also den Stop-Knopf.

Fix:
- Zwischen-Transkription laeuft als eigener Task. Der Tick bleibt frei und
  misst weiter alle 200ms — die Rechenzeit kann nicht mehr als Stille gelten.
  Laeuft noch eine, wird keine zweite gestartet.
- Zwischen-Transkriptionen sehen nur noch die letzten 30s an. Damit bleibt ihre
  Kosten konstant statt mit der Aufnahmelaenge zu wachsen. Der FINALE Text
  entsteht unveraendert aus dem gesamten Buffer mit beam_size=5 — es geht kein
  Wort verloren. stt_partial bekommt dafuer ein Feld 'windowed'.
- _finalize und drop brechen eine laufende Zwischen-Transkription ab, damit der
  finale Text nicht hinter ihr auf den GPU-Lock warten muss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:18:02 +02:00
duffyduckandClaude Opus 5 443b36bb9d fix(android-agent): Sprachausgabe meldet erst Erfolg, wenn wirklich Ton laeuft
Der Agent meldete "gesprochen", sobald die TTS-Anfrage im Socket lag. Ob sie
ueberhaupt rausging, ob der Server antwortete oder ob ein einziger Ton kam,
hat nie jemand geprueft. Auf dem Fire TV blieb es damit still, waehrend ARIA
"erledigt" sagte — ein Fehler, der sich nicht melden kann.

Drei Ursachen:
- OkHttps send() WIRFT NICHT, sondern gibt false zurueck, wenn die Verbindung
  zu ist. Der Rueckgabewert wurde verworfen: Senden ins Leere sah aus wie
  Erfolg. send() gibt das jetzt durch, requestTts wirft bei abgelehntem Senden.
- Niemand wartete auf Audio. Neu: awaitSpeechStart() wartet, bis fuer diese
  messageId wirklich Ton laeuft, sonst Fehler mit Grund.
- Warten darf NICHT im WS-Lese-Thread passieren: handle() laeuft dort, und das
  erwartete audio_pcm kommt ueber genau diese Verbindung — wir haetten uns
  selbst ausgehungert. speak/notify laufen jetzt in einem eigenen Thread und
  schicken ihr host_result von dort.

Brain-seitig haben speak/notify 40s statt 20s Zeit, damit die ehrliche Antwort
(TTS-Render + Uebertragung) nicht in den Timeout laeuft.

APK baut durch (Gradle, nur die zwei bekannten Deprecation-Warnungen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:17:48 +02:00
duffyduckandClaude Opus 5 12b8362d26 fix(trigger): Antwort landet im Projekt des Timers — und der Zustand luegt nicht mehr
Zwei Fehler, die zusammen wie "der Timer hat nicht gefeuert" aussahen.

1) Trigger hatten KEIN Projekt. Beim Feuern ging die Antwort ohne projectId
   durch _process_core_response — also immer in den Hauptchat, auch wenn der
   Timer mitten in einem Projekt-Thread gestellt wurde. Im Projekt blieb es
   still, und es sah aus wie "nicht gefeuert". Jetzt wird das Projekt des
   Requests mit angelegt, der getriggerte Turn laeuft darin, und die Antwort
   kommt dort zurueck.

2) trigger_list zeigte nur "aktiv" / "DEAKTIVIERT" und verschwieg fire_count
   und last_fired_at. Ein One-Shot-Timer wird NACH dem Feuern automatisch
   deaktiviert — "hat sauber gefeuert" und "war nie an" sahen damit identisch
   aus. ARIA hat genau das falsch gelesen und berichtet, sie habe vergessen den
   Timer zu aktivieren, obwohl er puenktlich gefeuert hatte. Die Ausgabe nennt
   jetzt ausdruecklich "SCHON GEFEUERT (Nx, zuletzt <Zeit>)" plus den Kontext,
   in dem die Antwort landet.

Nachgewiesen am Fall flugwetter-whv-freitag-0900: gefeuert 07:00:05 UTC
(= 09:00 lokal), Antwort 46s spaeter, fire_count=1 — aber active=false im
Manifest und die Antwort im Hauptchat statt im Projekt-Thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 10:17:35 +02:00
duffyduck e628170760 release(agent): bump to 0.0.1.3 2026-10-03 10:16:20 +02:00
duffyduck 7af0b76ad3 release: bump version to 0.2.5.5 2026-10-02 07:35:59 +02:00
duffyduckandClaude Opus 5 614ba48531 feat(app): Sprachnachricht zu Anhaengen + Zwischenruf per Sprache
Bisher war die Eingabeleiste ein Entweder-oder: sobald Text im Feld stand oder
ein Anhang dranhing, verschwand das Mikro. Damit ging genau das nicht, was man
eigentlich will — ein Bild anhaengen und den Auftrag dazu SPRECHEN. Und ein
Zwischenruf ging nur getippt.

Das Mikro ist jetzt immer da; Senden-Pfeil und Mund-/Ohr-Knoepfe weichen je
nach Lage, damit die Zeile nicht ueberlaeuft.

Anhang + Sprachnachricht:
- Mikro antippen mit wartenden Anhaengen laedt sie SOFORT hoch (parallel zum
  Reden) und markiert sie mit awaitVoice + clientMsgId.
- Die Bridge schickt solche Anhaenge nicht nach 0.8s alleine ans Brain, sondern
  wartet auf den stt_endpoint und fuehrt Anhang + gesprochenen Satz zu EINEM
  Turn zusammen. Ohne das sah ARIA ein Bild ohne Auftrag und danach einen Satz
  ohne Bild.
- Der Merge ist strikt an die clientMsgId gebunden (require_match): eine
  Sprachnachricht, die zufaellig waehrend eines fremden Uploads fertig wird,
  reisst dessen Anhaenge nicht an sich. Der Text-Pfad behaelt seinen Fallback.
- Bild und gesprochener Text landen in EINER Bubble (gemeinsame audioRequestId).
- Klappt das Mikro nicht, bleibt die Bubble mit den Bildern stehen statt zu
  verschwinden — die Dateien sind ja schon auf dem Server.
- Das Notfall-Fenster (420s) liegt ueber dem Aufnahme-Hard-Cap von 5 Minuten,
  sonst waeren die Anhaenge bei einer langen Aufnahme vorher alleine raus.

Zwischenruf per Sprache:
- Der 📣-Knopf erscheint jetzt immer, wenn ARIA im Kontext arbeitet. Mit Text
  schickt er sofort (wie bisher), ohne Text schaltet er die naechste Aufnahme
  scharf (sichtbar hervorgehoben, gilt fuer genau eine Aufnahme).
- Eine so markierte Aufnahme geht per stt_stream_start als interject raus; die
  Bridge schiebt sie in den laufenden Turn statt sie anzustellen.
- Dabei wird der laufende Turn NICHT abgebrochen (das war der Sinn) — nur die
  Wiedergabe stoppt, damit ARIA nicht ins offene Mikro redet.
- Die Bubble zeigt 📣 Zwischenruf statt 🎤, damit man nicht auf eine eigene
  Antwort wartet.

Merge-Logik gegen den echten Bridge-Code getestet (6 Faelle), tsc sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 02:11:06 +02:00
27 changed files with 2065 additions and 197 deletions
+176 -6
View File
@@ -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,
@@ -482,11 +483,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
@@ -514,6 +522,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 +830,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 +1033,92 @@ 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`) |
> 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
@@ -1073,14 +1188,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 +1319,47 @@ 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"
#### 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)
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20504
versionName "0.2.5.4"
versionCode 20505
versionName "0.2.5.5"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.4",
"version": "0.2.5.5",
"private": true,
"scripts": {
"android": "react-native run-android",
+162 -43
View File
@@ -217,6 +217,13 @@ function formatDur(sec: number): string {
// 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;
@@ -391,6 +398,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);
@@ -1324,7 +1341,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.
@@ -1339,7 +1360,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();
@@ -2413,11 +2436,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;
}, []);
@@ -2430,18 +2460,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,
@@ -2456,15 +2503,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
@@ -2570,6 +2639,11 @@ const ChatScreen: React.FC = () => {
// 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;
@@ -2597,16 +2671,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 || `${atts.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,
@@ -2644,6 +2725,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 }),
});
@@ -2651,8 +2735,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,
@@ -2665,6 +2749,9 @@ 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
@@ -3529,7 +3616,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}
@@ -3537,29 +3626,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 && (
@@ -4085,6 +4197,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,
},
+9
View File
@@ -1141,6 +1141,13 @@ class AudioService {
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
/** 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);
@@ -1245,6 +1252,8 @@ class AudioService {
sampleRate: 16000,
projectId: opts.projectId || '',
targetInstance: this.streamTargetInstance,
...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}),
...(opts.interject ? { interject: true } : {}),
});
const started = await PcmStreamRecorder.start();
+147 -21
View File
@@ -820,13 +820,13 @@ META_TOOLS = [
"function": {
"name": "oauth_get_token",
"description": (
"Liefert das aktuelle access_token fuer einen Service. "
"Refresht automatisch wenn abgelaufen (oder < 60s Restzeit) "
"und der Provider einen refresh_token mitgegeben hat.\n\n"
"**Nutze das in Skills** wenn Du Provider-APIs callen willst — "
"der token kommt als Bearer-Header in Deinen HTTP-Request, "
"z.B. `Authorization: Bearer <token>`.\n\n"
"Wirft wenn Service noch nicht authentifiziert ist oder der "
"Prueft, ob ein Service authentifiziert ist, und wie lange der "
"Token noch gilt. Refresht dabei automatisch, wenn noetig.\n\n"
"GIBT DEN TOKEN NICHT HERAUS — und Du brauchst ihn auch nicht: "
"fuer API-Aufrufe nimm `oauth_call`, das Brain haengt die "
"Authorization selbst an. In einem Skill holst Du ihn zur "
"Laufzeit ueber {BRAIN_INTERNAL_URL}/oauth/<service>/token.\n\n"
"Wirft, wenn der Service noch nicht authentifiziert ist oder der "
"Refresh fehlschlaegt → dann erst `oauth_authorize` aufrufen."
),
"parameters": {
@@ -838,6 +838,35 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "oauth_call",
"description": (
"Ruft eine Provider-API MIT dem hinterlegten Zugangstoken auf, "
"ohne dass Du den Token siehst. Das Brain haengt den "
"Authorization-Header an, fuehrt den Request aus und gibt Dir nur "
"die Antwort.\n\n"
"Nimm das fuer einzelne Aufrufe (z.B. Spotify-Wiedergabe steuern, "
"GitHub-Issue anlegen). Fuer etwas, das Du oefter brauchst, bau "
"lieber einen Skill.\n\n"
"Nur https, und nur Hosts, die zum Service gehoeren — eine fremde "
"Domain wird abgelehnt (sonst koennte der Token irgendwohin "
"geschickt werden). Antwort wird bei 200 KB gekuerzt."
),
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "z.B. spotify, github."},
"url": {"type": "string", "description": "Vollstaendige https-URL der API."},
"method": {"type": "string", "description": "GET (Default), POST, PUT, PATCH, DELETE."},
"body": {"type": "string", "description": "Request-Body, meist JSON als String."},
"content_type": {"type": "string", "description": "Default application/json."},
},
"required": ["service", "url"],
},
},
},
{
"type": "function",
"function": {
@@ -1599,7 +1628,9 @@ META_TOOLS = [
"das NICHT ueber host_exec/adb (dort gibt es kein passendes Kommando). "
"mode: 'overlay' (einblenden), 'speech' (vorlesen) oder 'both'; ohne "
"Angabe gilt die Einstellung am Geraet. Braucht dort einmalig die "
"Berechtigung 'Ueber anderen Apps anzeigen'."
"Berechtigung 'Ueber anderen Apps anzeigen'. Mit image_path kann "
"ein BILD mit Text darueber eingeblendet werden (z.B. ein "
"generiertes Bild oder ein Screenshot) — nur bei Android-Agenten."
),
"parameters": {
"type": "object",
@@ -1607,9 +1638,28 @@ META_TOOLS = [
"host": {"type": "string", "description": "Host-ID/Name (z.B. 'Fire-TV Schlafzimmer')."},
"text": {"type": "string", "description": "Der anzuzeigende Text."},
"title": {"type": "string", "description": "Ueberschrift (Default 'ARIA')."},
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
"seconds": {
"type": "integer",
"description": (
"Anzeigedauer in Sekunden (Default 12). "
"0 = stehen lassen, bis Stefan selbst wegdrueckt "
"(Fernbedienung: beliebige Taste, Handy: daneben "
"tippen). Nimm 0 fuer alles zum LESEN — Wetter, "
"Listen, Zahlen: eine feste Dauer ist da immer "
"geraten und meist zu kurz. Kurze Hinweise waehrend "
"eines Films ruhig mit Dauer."
),
},
"mode": {"type": "string", "description": "overlay | speech | both"},
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
"image_path": {
"type": "string",
"description": (
"Optional: Pfad zu einem Bild (PNG/JPEG) unter "
"/shared/uploads/, das MIT dem Text eingeblendet wird. "
"Nur Android-Agenten koennen das."
),
},
},
"required": ["host", "text"],
},
@@ -3246,8 +3296,31 @@ class Agent:
if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name]
params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
# Bild-Einblendung: ARIA gibt einen Pfad an (damit arbeitet sie
# sowieso), der Agent will base64. Hier umwandeln, statt ARIA
# megabyteweise base64 durch den Tool-Call schieben zu lassen.
img_path = (arguments.get("image_path") or "").strip()
if action == "notify" and img_path:
if not img_path.startswith("/shared/uploads/"):
return "FEHLER: image_path muss unter /shared/uploads/ liegen."
if not os.path.isfile(img_path):
return f"FEHLER: Bild nicht gefunden: {img_path}"
# Overlay-Bilder ueber ~4 MB sind sinnlos gross fuer einen
# Fernseher und blaehen die WS-Nachricht unnoetig auf.
_img_size = os.path.getsize(img_path)
if _img_size > 4 * 1024 * 1024:
return (f"FEHLER: Bild zu gross fuer eine Einblendung "
f"({_img_size // (1024 * 1024)} MB > 4 MB). "
f"Vorher verkleinern.")
with open(img_path, "rb") as _f:
params["image_base64"] = _b64.b64encode(_f.read()).decode("ascii")
# Sprachausgabe braucht laenger: der Agent meldet erst Erfolg,
# wenn wirklich Ton laeuft (TTS-Render + Uebertragung). Mit den
# 20s der UI-Aktionen lief die ehrliche Antwort in den Timeout.
_timeout = 40 if action in ("speak", "notify") else 20
result = _post("/internal/host",
{"host": host, "action": action, "params": params}, 20)
{"host": host, "action": action, "params": params},
_timeout)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
@@ -3512,6 +3585,10 @@ class Agent:
fires_at_iso=fires_at_iso,
message=arguments["message"],
author="aria",
# Projekt DIESES Requests mitnehmen: beim Feuern soll die
# Antwort dort landen, wo der Timer gestellt wurde — nicht
# im Hauptchat.
project_id=project_id or "",
)
self._pending_events.append({
"type": "trigger_created",
@@ -3527,6 +3604,7 @@ class Agent:
check_interval_sec=int(arguments.get("check_interval_sec", 300)),
throttle_sec=int(arguments.get("throttle_sec", 3600)),
author="aria",
project_id=project_id or "",
)
self._pending_events.append({
"type": "trigger_created",
@@ -3599,13 +3677,29 @@ class Agent:
return "(keine Trigger vorhanden)"
lines = []
for t in items:
state = "aktiv" if t.get("active", True) else "DEAKTIVIERT"
if t["type"] == "timer":
lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
elif t["type"] == "watcher":
lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
# WICHTIG: ein One-Shot-Timer wird NACH dem Feuern automatisch
# deaktiviert. Stand hier nur "DEAKTIVIERT", sahen "hat sauber
# gefeuert" und "war nie an" identisch aus — und genau das hat
# schon zu einer falschen Auskunft gefuehrt ("ich habe
# vergessen ihn zu aktivieren", obwohl er gefeuert hatte).
# Darum: gefeuert IMMER explizit mit Zeitpunkt und Zaehler.
fired_n = int(t.get("fire_count", 0) or 0)
last_fired = t.get("last_fired_at") or ""
if fired_n > 0:
state = (f"SCHON GEFEUERT ({fired_n}x, zuletzt {last_fired})"
+ ("" if t.get("active", True) else ", danach automatisch deaktiviert"))
elif t.get("active", True):
state = "aktiv, noch nicht gefeuert"
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
state = "DEAKTIVIERT, nie gefeuert"
# Wohin die Antwort beim Feuern geht — leer = Hauptchat.
where = f" [Kontext: {t.get('project_id') or 'Hauptchat'}]"
if t["type"] == "timer":
lines.append(f"- {t['name']} (timer, {state}){where}: feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"")
elif t["type"] == "watcher":
lines.append(f"- {t['name']} (watcher, {state}){where}: cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
else:
lines.append(f"- {t['name']} ({t['type']}, {state}){where}")
return "\n".join(lines)
if name == "oauth_register_provider":
svc = (arguments.get("service") or "").strip()
@@ -3669,15 +3763,47 @@ class Agent:
record = oauth_mod.get_token(svc)
except RuntimeError as exc:
return f"FEHLER: {exc}"
tok = record.get("access_token", "")
ttype = record.get("token_type", "Bearer")
exp = record.get("expires_at", 0)
remain = max(0, int(exp) - int(__import__("time").time()))
hosts = ", ".join(oauth_mod.allowed_hosts(svc)) or "(keine hinterlegt)"
# Der Token wird hier BEWUSST nicht mehr herausgegeben. Frueher
# stand er im Klartext in dieser Tool-Antwort und damit im
# Gespraechsprotokoll — also auch bei Anthropic. Fuer API-Aufrufe
# gibt es oauth_call: das Brain haengt den Header selbst an.
return (
f"OK — Token fuer {svc} (Typ: {ttype}, gueltig noch {remain}s).\n"
f"access_token: {tok}\n"
f"Nutze als HTTP-Header: Authorization: {ttype} {tok}"
f"OK — {svc} ist authentifiziert (Token gueltig noch {remain}s).\n"
f"Der Token wird NICHT herausgegeben und ist dafuer auch nicht noetig:\n"
f"- Einzelne API-Aufrufe: `oauth_call(service='{svc}', url=...)` — "
f"das Brain haengt die Authorization an.\n"
f"- In einem Skill: GET {{BRAIN_INTERNAL_URL}}/oauth/{svc}/token "
f"zur Laufzeit holen, nie hartkodieren.\n"
f"Erlaubte Hosts: {hosts}"
)
if name == "oauth_call":
svc = (arguments.get("service") or "").strip()
url = (arguments.get("url") or "").strip()
if not svc or not url:
return "FEHLER: service und url sind Pflicht."
body = arguments.get("body")
if body is not None and not isinstance(body, str):
body = json.dumps(body, ensure_ascii=False)
try:
status, text = oauth_mod.call(
svc, url,
method=(arguments.get("method") or "GET"),
body=body,
content_type=(arguments.get("content_type") or "application/json"),
)
except ValueError as exc:
return f"FEHLER: {exc}"
except RuntimeError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_call fehlgeschlagen")
return f"FEHLER: Aufruf fehlgeschlagen: {exc}"
ok = 200 <= status < 300
head = f"HTTP {status}" + ("" if ok else " (Fehler)")
return f"{head}\n{text}" if text else head
if name == "oauth_revoke":
svc = (arguments.get("service") or "").strip()
if not svc:
+15 -4
View File
@@ -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
View File
@@ -398,6 +398,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
+46 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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 "
+20 -8
View File
@@ -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
View File
@@ -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
+119 -16
View File
@@ -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).
@@ -808,6 +816,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.
@@ -1879,6 +1899,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 +1919,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 +1942,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 +1972,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))
@@ -2752,10 +2790,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 +3369,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 +3395,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.
@@ -3427,11 +3482,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":
@@ -3891,7 +3971,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.
@@ -3960,6 +4041,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 ""),
@@ -4525,11 +4618,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":
@@ -5140,7 +5237,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, ...),
@@ -5192,7 +5290,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")
+499 -15
View File
@@ -602,6 +602,24 @@
selbst (Skills + skill_create Meta-Tool). Es gibt keine granulare
Permission-Maske, Brain weiss zur Laufzeit welche Tools es hat. -->
<!-- Warnung: Proxy laeuft mit veralteten Patches.
Der Proxy kopiert proxy-patches/ beim START in den npm-Code. Wer nach
einem git pull nur neu startet statt force-recreate — oder vor dem
Pull neu startet — laeuft weiter mit der alten Fassung, ohne dass es
irgendwo auffaellt. Das hat schon zweimal Zeit gekostet: einmal wirkte
ein Fix nicht, einmal blieb die Modell-Liste alt. -->
<div id="proxy-patch-warning" style="display:none;margin-bottom:16px;padding:12px 14px;
background:#2A1A08;border:1px solid #FF9F0A;border-radius:8px;">
<div style="color:#FF9F0A;font-size:13px;font-weight:600;margin-bottom:6px;">
⚠ Der Proxy läuft mit veralteten Patches
</div>
<div id="proxy-patch-detail" style="color:#C8A878;font-size:11px;line-height:1.6;"></div>
<div style="margin-top:8px;">
<code style="background:#12121E;padding:4px 8px;border-radius:4px;color:#E0E0F0;font-size:11px;display:inline-block;">
docker compose up -d --force-recreate proxy</code>
</div>
</div>
<!-- Brain-Model-Einstellung -->
<div class="settings-section">
<h2>Sprachmodell (Brain)</h2>
@@ -611,34 +629,79 @@
<code>/shared/config/runtime.json</code> als <code>brainModel</code> persistiert.
Bei Aenderung: <strong>aria-brain restarten</strong> (Reparatur-Section oben), damit's greift.
<br><br>
ARIA laeuft ueber dein Claude-Max-Abo (CLI) — waehlbar ist der <strong>Tier</strong>
(Opus/Sonnet/Haiku), nicht eine feste Version. Die CLI nimmt automatisch das
jeweils aktuelle Modell des Tiers.
ARIA laeuft ueber dein Claude-Max-Abo (CLI). Du hast zwei Möglichkeiten:
<br>
<strong>Automatisch</strong> (Opus/Sonnet/Haiku/Fable) — folgt immer der neuesten
Version dieses Tiers, ohne dass du etwas pflegen musst. Für den Alltag die
richtige Wahl.
<br>
<strong>Feste Version</strong> (z.B. „Opus 5.5") — genau dieses Modell, auch wenn
später ein neueres kommt.
<br><br>
Die Auswahlliste kommt aus <code>/shared/config/models.json</code> — dort kannst du
Tiers/Beschreibungen anpassen (kein Neustart noetig, danach „↻ Aktualisieren").
Die Liste kommt aus Anthropics eigenem <strong>Modell-Katalog</strong>
(<code>downloads.claude.ai</code>) — dieselbe Quelle, aus der sich Claude Code
selbst bedient. „↻ Aktualisieren" holt sie frisch; sonst gilt ein 6-Stunden-Cache.
Die automatischen Tiers kommen weiterhin aus
<code>/shared/config/models.json</code>.
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:12px;color:#8888AA;white-space:nowrap;">Aktives Model:</span>
<select id="setting-model-select" onchange="onModelSelectChange()" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="">(lade Liste…)</option>
</select>
<button class="btn secondary" onclick="loadModelList()" title="Liste vom Proxy neu holen" style="padding:4px 8px;font-size:10px;">↻ Aktualisieren</button>
<button class="btn secondary" onclick="loadModelList(true)" title="Modell-Liste frisch von Anthropic holen (Modell-Katalog)" style="padding:4px 8px;font-size:10px;">↻ Aktualisieren</button>
<button class="btn secondary" onclick="testModel()" id="btn-test-model" title="Schickt eine echte Mini-Anfrage und zeigt, welches Modell geantwortet hat" style="padding:4px 8px;font-size:10px;">🔬 Testen</button>
<button class="btn" onclick="saveModel()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
<div id="model-test-result" style="font-size:11px;color:#8888AA;margin-bottom:8px;padding:6px 8px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;display:none;white-space:pre-wrap;"></div>
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
<details style="font-size:11px;color:#8888AA;">
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<input type="text" id="setting-model" placeholder="z.B. claude-opus-4" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<input type="text" id="setting-model" placeholder="z.B. claude-opus-5-5" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<button class="btn secondary" onclick="loadModel()" style="padding:4px 8px;font-size:10px;">Laden</button>
<button class="btn secondary" onclick="testModel(true)" style="padding:4px 8px;font-size:10px;" title="Diese ID testen, bevor du sie setzt">🔬 Testen</button>
<button class="btn" onclick="saveModelFreeText()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
<div style="color:#555570;font-size:10px;margin-top:6px;">
Erst testen, dann setzen — ein Tippfehler im Namen fällt sonst erst
auf, wenn ARIA nicht mehr antwortet.
</div>
</details>
</div>
</div>
<div class="settings-section">
<h2>Datenschutz — was Claude Code von sich aus sendet</h2>
<div class="card" style="max-width:620px;">
<div style="font-size:11px;color:#8888AA;margin-bottom:12px;line-height:1.55;">
Drei Kanäle, über die Claude Code <strong>zusätzlich</strong> zur eigentlichen
Anfrage Daten verschickt.
<br><br>
<span style="color:#3FFF9F;">✓ Kein Neustart nötig.</span>
Gesetzt wird in <code>~/.claude/settings.json</code>, und die Claude-CLI liest
die Datei bei <em>jedem</em> Aufruf neu. Speichern genügt — die nächste Anfrage
nutzt die neue Einstellung. Nur ein <em>gerade laufender</em> Turn arbeitet noch
mit der alten, weil sein Prozess schon gestartet ist.
<br><br>
Davon unabhängig ist, ob Anthropic mit deinen Unterhaltungen Modelle
trainiert — das steht in deinem Konto unter
<a href="https://claude.ai/settings/data-privacy-controls" target="_blank"
style="color:#0096FF;">Datenschutz → „Help improve our AI models"</a>
und lässt sich hier nicht umstellen.
</div>
<div id="privacy-flags-list" style="display:flex;flex-direction:column;gap:10px;">
<span style="color:#555570;font-size:11px;">(noch nicht geprüft)</span>
</div>
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap;">
<button class="btn secondary" onclick="loadPrivacyFlags()" style="padding:5px 12px;font-size:11px;">🔍 Prüfen</button>
<button class="btn" onclick="savePrivacyFlags()" style="padding:5px 12px;font-size:11px;">Speichern</button>
</div>
<div id="privacy-flags-status" style="margin-top:10px;font-size:11px;color:#8888AA;white-space:pre-wrap;"></div>
</div>
</div>
<div class="settings-section">
<h2>Lokales LLM (schnelle Antworten) <button class="info-btn" onclick="showInfo('local-llm')" title="Wie funktioniert das?">ℹ</button></h2>
<div class="card" style="max-width:540px;">
@@ -1051,6 +1114,31 @@
<div class="settings-subtab" data-sub="system">
<!-- Anhang-Limit -->
<div class="settings-section">
<h2>Grösse von Anhängen</h2>
<div class="card" style="max-width:560px;line-height:1.6;">
<p style="color:#8888AA;font-size:12px;margin:0 0 10px;">
Grösste Datei, die an einen Gedanken angehängt werden darf.
Greift <strong>sofort</strong> — kein Neustart nötig.
</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="number" id="attachment-limit-input" min="1" max="1024" step="1"
style="width:110px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;
color:#E0E0F0;padding:8px 10px;font-size:13px;">
<span style="color:#8888AA;font-size:12px;">MB</span>
<button class="btn secondary" onclick="saveAttachmentLimit()">Speichern</button>
</div>
<p style="color:#8888AA;font-size:11px;margin:10px 0 0;">
Über <strong>64 MB</strong> lädt die Handy-App Anhänge nicht mehr herunter —
ein Anhang muss als Base64 komplett in den Speicher, und daran ist die App
schon einmal gestorben. Er liegt dann sauber auf dem Server, ist mobil aber
nur noch über den Dateimanager erreichbar.
</p>
<div id="attachment-limit-status" style="margin-top:10px;font-size:11px;color:#8888AA;"></div>
</div>
</div>
<!-- Reparatur & Restart -->
<div class="settings-section">
<h2>Reparatur & Restart</h2>
@@ -1371,9 +1459,42 @@
<option value="pinned">📌 Nur Pinned</option>
<option value="cold">Nur Cold</option>
</select>
<select id="brain-sort" onchange="onBrainFiltersChanged()"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;"
title="Sortierung der Liste">
<option value="type">Nach Typ gruppiert</option>
<option value="updated_desc">Neueste Änderung zuerst</option>
<option value="updated_asc">Älteste Änderung zuerst</option>
<option value="created_desc">Neueste Anlage zuerst</option>
<option value="created_asc">Älteste Anlage zuerst</option>
</select>
<button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button>
<button class="btn secondary" onclick="toggleDateFilter()" id="btn-date-filter" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Nach Zeitraum filtern">🕒 Zeitraum</button>
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button>
</div>
<div id="brain-date-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11px;color:#8888AA;">
<select id="brain-date-field"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
<option value="updated">geändert</option>
<option value="created">angelegt</option>
</select>
<span>von</span>
<input type="datetime-local" id="brain-date-from"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
<span>bis</span>
<input type="datetime-local" id="brain-date-to"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
<button class="btn" onclick="onBrainFiltersChanged()" style="padding:4px 12px;font-size:11px;">Filtern</button>
<button class="btn secondary" onclick="clearDateFilter()" style="padding:4px 10px;font-size:11px;color:#8888AA;">Leeren</button>
</div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap;">
<button class="btn secondary" onclick="setDateQuick(1)" style="padding:3px 9px;font-size:10px;">Heute</button>
<button class="btn secondary" onclick="setDateQuick(7)" style="padding:3px 9px;font-size:10px;">7 Tage</button>
<button class="btn secondary" onclick="setDateQuick(30)" style="padding:3px 9px;font-size:10px;">30 Tage</button>
<span style="color:#555570;font-size:10px;margin-left:auto;">Nur ein Feld ausfüllen = offenes Ende</span>
</div>
</div>
<div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
<div style="color:#8888AA;font-size:11px;margin-bottom:6px;">
Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet.
@@ -1732,7 +1853,7 @@
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
<label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;">
<span>📎 Anhaenge</span>
<span style="color:#555570;font-size:10px;">max 20 MB pro Datei</span>
<span class="attachment-limit-label" style="color:#555570;font-size:10px;">Limit: Einstellungen → System &amp; Wartung</span>
</label>
<div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div>
<div style="display:flex;gap:6px;align-items:center;">
@@ -2650,6 +2771,97 @@
return;
}
if (msg.type === 'proxy_patch_state') {
const box = document.getElementById('proxy-patch-warning');
const det = document.getElementById('proxy-patch-detail');
if (box) {
const stale = msg.stale || [];
box.style.display = stale.length ? 'block' : 'none';
if (stale.length && det) {
const fmt = (ms) => new Date(ms).toLocaleString('de-DE',
{ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
det.innerHTML =
'Diese Dateien wurden nach dem letzten Proxy-Start geändert — der Proxy '
+ 'arbeitet noch mit der alten Fassung:<br>'
+ stale.map(s => '• <code>' + escapeHtml(s.file) + '</code> — geändert '
+ fmt(s.srcAt) + ', eingespielt ' + fmt(s.activeAt)).join('<br>')
+ '<br><br>Der Proxy kopiert die Patches nur beim <strong>Start</strong>. '
+ 'Ein <code>--build</code> reicht nicht:';
}
}
return;
}
if (msg.type === 'privacy_flags') { renderPrivacyFlags(msg); return; }
if (msg.type === 'privacy_flags_saved') {
const st = document.getElementById('privacy-flags-status');
if (st && msg.error) { st.textContent = 'Speichern fehlgeschlagen: ' + msg.error; st.style.color = '#FF6B6B'; }
// Erfolg: der direkt folgende privacy_flags-Broadcast malt den Stand neu.
return;
}
if (msg.type === 'model_test_result') {
const box = document.getElementById('model-test-result');
const btn = document.getElementById('btn-test-model');
if (btn) { btn.disabled = false; btn.textContent = '🔬 Testen'; }
if (box) {
box.style.display = 'block';
const secs = msg.tookMs ? ' · ' + (msg.tookMs / 1000).toFixed(1) + 's' : '';
if (msg.error) {
box.textContent = '❌ "' + msg.requested + '" hat nicht funktioniert'
+ secs + '\n' + msg.error;
box.style.color = '#FF6B6B';
} else {
// Zeigt ausdruecklich BEIDES: was angefragt wurde und was
// tatsaechlich lief.
const TIERS = ['opus', 'sonnet', 'haiku', 'fable'];
const differs = msg.actual !== msg.requested;
const isTier = TIERS.includes(msg.requested);
const win = msg.contextWindow
? '\nKontext: ' + Math.round(msg.contextWindow / 1000) + 'k Token'
+ (msg.maxOutput ? ', max. Ausgabe ' + Math.round(msg.maxOutput / 1000) + 'k' : '')
: '';
const tail = win + (msg.reply ? '\nAntwort: ' + msg.reply : '');
if (differs && !isTier) {
// Ein nicht erkannter Name wird vom Proxy durch "opus" ersetzt.
// Das darf NICHT wie ein Erfolg aussehen — sonst setzt man eine
// Modell-ID, die nie benutzt wird, und merkt es nie.
box.textContent = '⚠ "' + msg.requested + '" wurde nicht erkannt' + secs
+ '\nStattdessen geantwortet hat: ' + msg.actual
+ '\nDiese ID so NICHT setzen — tippe den vollen Namen (z.B. '
+ 'claude-opus-5-5) oder nimm einen Eintrag aus der Liste.' + tail;
box.style.color = '#FFD60A';
} else {
box.textContent = '✅ Geantwortet hat: ' + msg.actual + secs
+ (differs ? '\n(angefragt war der Tier "' + msg.requested
+ '" — aufgelöst auf diese Version)' : '')
+ tail;
box.style.color = '#3FFF9F';
}
}
}
return;
}
if (msg.type === 'attachment_limit_info') {
const inp = document.getElementById('attachment-limit-input');
const st = document.getElementById('attachment-limit-status');
if (inp && msg.maxMb != null) inp.value = msg.maxMb;
// Das Label an der Upload-Stelle im Gehirn-Tab mitziehen, damit dort
// nicht weiter ein festes "max 20 MB" steht das nicht mehr stimmt.
if (msg.maxMb != null) {
attachmentLimitMb = msg.maxMb;
document.querySelectorAll('.attachment-limit-label').forEach(el => {
el.textContent = 'max ' + msg.maxMb + ' MB pro Datei';
});
}
if (st) {
st.textContent = msg.error ? ('Fehler: ' + msg.error) : (msg.info || '');
st.style.color = msg.error ? '#FF6B6B'
: (msg.maxMb > (msg.softMb || 64) ? '#FFD60A' : '#3FFF9F');
}
return;
}
// session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus.
if (msg.type === 'model_info') {
const el = document.getElementById('setting-model');
@@ -4543,6 +4755,22 @@
// ── Haupt-Tab Navigation ──────────────────────────────────
// Zuletzt bekanntes Anhang-Limit (MB) — nur fuer Anzeige/Vorab-Pruefung.
// Die verbindliche Pruefung macht das Brain.
let attachmentLimitMb = null;
function saveAttachmentLimit() {
const inp = document.getElementById('attachment-limit-input');
const st = document.getElementById('attachment-limit-status');
const v = Number(inp && inp.value);
if (!isFinite(v) || v <= 0) {
if (st) { st.textContent = 'Bitte eine Zahl grösser 0 angeben.'; st.style.color = '#FF6B6B'; }
return;
}
if (st) { st.textContent = 'Speichere …'; st.style.color = '#8888AA'; }
send({ action: 'set_attachment_limit', maxMb: v });
}
function switchSettingsTab(sub) {
sub = sub || 'models';
document.querySelectorAll('.settings-subtab').forEach(el => el.classList.toggle('visible', el.dataset.sub === sub));
@@ -4567,9 +4795,14 @@
loadOAuthServices();
loadJellyfinConfig();
refreshVoiceIdStatus();
send({ action: 'get_attachment_limit' });
send({ action: 'get_privacy_flags' });
send({ action: 'check_proxy_patches' });
} else if (tab === 'brain') {
loadBrainStatus();
loadBrainMemoryList();
// Damit das Anhang-Label die echte Zahl zeigt, nicht eine fest eingetippte.
send({ action: 'get_attachment_limit' });
refreshImportFiles();
loadMetrics();
loadProjects();
@@ -6493,6 +6726,14 @@
const t = document.getElementById('brain-filter-type'); if (t) t.value = '';
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
const so = document.getElementById('brain-sort'); if (so) so.value = 'type';
// Zeitraum-Panel zu + leeren, sonst filtert es nach dem ✕ unsichtbar weiter
const dp = document.getElementById('brain-date-panel');
if (dp) dp.style.display = 'none';
const db = document.getElementById('btn-date-filter');
if (db) db.style.color = '#8888AA';
const df = document.getElementById('brain-date-from'); if (df) df.value = '';
const dt = document.getElementById('brain-date-to'); if (dt) dt.value = '';
brainSearchIds = null;
clearAdvancedSearch();
}
@@ -6745,7 +6986,10 @@
if (!el) return;
// Wenn aktive Search-Treffer da sind: die anzeigen, nicht neu laden
if (brainSearchIds && brainSearchIds.length) {
const items = brainSearchIds.map(id => brainMemoryCache[id]).filter(Boolean);
// Zeitraum gilt auch fuer Such-Treffer — sonst waere "suche X,
// aber nur von letzter Woche" nicht moeglich.
const items = applyDateFilter(
brainSearchIds.map(id => brainMemoryCache[id]).filter(Boolean));
renderBrainList(items, true);
return;
}
@@ -6761,8 +7005,12 @@
items.forEach(m => { brainMemoryCache[m.id] = m; });
if (pinnedFilter === 'pinned') items = items.filter(m => m.pinned);
else if (pinnedFilter === 'cold') items = items.filter(m => !m.pinned);
const before = items.length;
items = applyDateFilter(items);
if (!items.length) {
el.innerHTML = '(Keine Memories — leere DB oder Filter zu eng)';
el.innerHTML = before > 0
? `(Kein Gedanke im gewählten Zeitraum — ${before} wurden durch den Zeitfilter ausgeblendet)`
: '(Keine Memories — leere DB oder Filter zu eng)';
return;
}
renderBrainList(items, false);
@@ -6831,6 +7079,31 @@
`);
}
// ─── Datum/Uhrzeit eines Gedankens ────────────────────────────────
// created_at / updated_at liegen schon immer als ISO-String in der DB —
// sie wurden nur nie angezeigt. Darum sah man nicht, was der letzte
// Eintrag war.
function memTs(m, which) {
const raw = which === 'created' ? m.created_at : (m.updated_at || m.created_at);
if (!raw) return 0;
const t = Date.parse(raw);
return isFinite(t) ? t : 0;
}
function fmtMemTs(ms) {
if (!ms) return '';
return new Date(ms).toLocaleString('de-DE',
{ day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
}
/** "vor 3 Min" / "vor 2 Std" / "vor 5 Tagen" — fuer "was war der letzte Eintrag". */
function fmtMemAgo(ms) {
if (!ms) return '';
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 60) return 'gerade';
if (s < 3600) return `vor ${Math.floor(s / 60)} Min`;
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std`;
return `vor ${Math.floor(s / 86400)} Tg`;
}
function renderMemoryRow(m, withScore) {
const pin = m.pinned ? '📌 ' : '';
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
@@ -6844,12 +7117,25 @@
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
: '';
// Zeitstempel-Zeile: angelegt immer, geaendert nur wenn es sich
// unterscheidet (sonst stehen zwei identische Zeiten nebeneinander).
const cMs = memTs(m, 'created');
const uMs = memTs(m, 'updated');
const tsLine = (cMs || uMs)
? `<div style="color:#555570;font-size:10px;margin-top:2px;">`
+ (cMs ? `angelegt ${fmtMemTs(cMs)}` : '')
+ (uMs && Math.abs(uMs - cMs) > 60000
? ` · geändert ${fmtMemTs(uMs)} <span style="color:#0096FF;">(${fmtMemAgo(uMs)})</span>`
: (cMs ? ` <span style="color:#0096FF;">(${fmtMemAgo(cMs)})</span>` : ''))
+ `</div>`
: '';
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
${tsLine}
</div>
<button class="btn secondary" onclick="event.stopPropagation();openMemoryModal('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;" title="Bearbeiten">✎</button>
<button class="btn secondary" onclick="event.stopPropagation();deleteMemory('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
@@ -6867,6 +7153,71 @@
</div>`;
}
/** Sortiert nach der Auswahl im brain-sort-Dropdown. 'type' = unveraendert. */
function sortMemories(items) {
const mode = document.getElementById('brain-sort')?.value || 'type';
if (mode === 'type') return items;
const field = mode.startsWith('created') ? 'created' : 'updated';
const desc = mode.endsWith('_desc');
// Kopie sortieren — die Aufrufer halten teils denselben Array weiter.
return items.slice().sort((a, b) => {
const d = memTs(a, field) - memTs(b, field);
return desc ? -d : d;
});
}
/** Zeitraum-Filter aus dem 🕒-Panel. Leere Felder = offenes Ende. */
function applyDateFilter(items) {
const panel = document.getElementById('brain-date-panel');
if (!panel || panel.style.display === 'none') return items;
const field = document.getElementById('brain-date-field')?.value || 'updated';
const fromRaw = document.getElementById('brain-date-from')?.value || '';
const toRaw = document.getElementById('brain-date-to')?.value || '';
const from = fromRaw ? Date.parse(fromRaw) : null;
const to = toRaw ? Date.parse(toRaw) : null;
if (from == null && to == null) return items;
return items.filter(m => {
const t = memTs(m, field);
if (!t) return false; // ohne Zeitstempel nicht in einen Zeitraum einsortierbar
if (from != null && t < from) return false;
if (to != null && t > to) return false;
return true;
});
}
function toggleDateFilter() {
const panel = document.getElementById('brain-date-panel');
const btn = document.getElementById('btn-date-filter');
if (!panel) return;
const open = panel.style.display === 'none';
panel.style.display = open ? 'block' : 'none';
if (btn) btn.style.color = open ? '#FFD60A' : '#8888AA';
if (!open) loadBrainMemoryList(); // zugeklappt = Filter aus
}
function clearDateFilter() {
const f = document.getElementById('brain-date-from');
const t = document.getElementById('brain-date-to');
if (f) f.value = '';
if (t) t.value = '';
loadBrainMemoryList();
}
/** Schnellwahl: letzte N Tage (0 Uhr bis jetzt). */
function setDateQuick(days) {
const now = new Date();
const from = new Date(now.getTime() - (days - 1) * 86400000);
from.setHours(0, 0, 0, 0);
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const ef = document.getElementById('brain-date-from');
const et = document.getElementById('brain-date-to');
if (ef) ef.value = fmt(from);
if (et) et.value = fmt(now);
loadBrainMemoryList();
}
function renderBrainList(items, isSearchResult) {
const el = document.getElementById('brain-memory-list');
if (!el) return;
@@ -6875,11 +7226,22 @@
_updateCategoryDatalist(items);
if (isSearchResult) {
// Such-Treffer: in Aehnlichkeits-Reihenfolge, kein Type-Gruppieren
const html = items.map(m => renderMemoryRow(m, true)).join('');
// Such-Treffer: Aehnlichkeits-Reihenfolge, ausser es ist bewusst eine
// Zeit-Sortierung gewaehlt — dann gilt die auch fuer Treffer.
const sorted = sortMemories(items);
const html = sorted.map(m => renderMemoryRow(m, true)).join('');
el.innerHTML = html || '(Keine Treffer)';
return;
}
// Zeit-Sortierung gewaehlt → flache Liste, keine Typ-Gruppen. Das ist der
// Sinn der Sache: man will sehen, was zuletzt passiert ist, nicht in
// welche Schublade es gehoert.
const sortMode = document.getElementById('brain-sort')?.value || 'type';
if (sortMode !== 'type') {
const html = sortMemories(items).map(m => renderMemoryRow(m, false)).join('');
el.innerHTML = html || '(Keine Memories)';
return;
}
// Normale Liste: nach Type gruppieren, Header klappbar
const byType = {};
items.forEach(m => { (byType[m.type] = byType[m.type] || []).push(m); });
@@ -7673,17 +8035,46 @@
// Kuratierte Tier-Liste vom Proxy holen (via Diagnostic-Server) und ins
// Dropdown fuellen. Danach get_model, damit das aktive Model markiert wird.
async function loadModelList() {
// force=true (Knopf „↻ Aktualisieren") holt die Liste frisch von Anthropic
// statt aus dem Proxy-Cache.
async function loadModelList(force) {
const sel = document.getElementById('setting-model-select');
const st = document.getElementById('model-status');
if (!sel) return;
if (force && st) { st.textContent = 'Hole Modell-Liste von Anthropic …'; st.style.color = '#8888AA'; }
try {
const r = await fetch('/api/models-list');
const r = await fetch('/api/models-list' + (force ? '?refresh=1' : ''));
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
_modelListCache = d.models || [];
sel.innerHTML = '';
for (const m of _modelListCache) {
// Nach Herkunft gruppieren, damit die Wahl klar ist:
// Tier — folgt automatisch der neuesten Version (Empfehlung)
// main — die aktuell von Anthropic vorgeschlagenen Versionen
// overflow— aeltere Versionen, nur wenn man sie bewusst will
const GROUPS = [
['tier', 'Automatisch (folgt der neuesten Version)'],
['main', 'Aktuelle Versionen'],
['overflow', 'Ältere Versionen'],
];
for (const [section, label] of GROUPS) {
const inGroup = _modelListCache.filter(m => (m.section || 'tier') === section);
if (!inGroup.length) continue;
const g = document.createElement('optgroup');
g.label = label;
for (const m of inGroup) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.displayName || m.id;
g.appendChild(opt);
}
sel.appendChild(g);
}
// Unbekannte Sektionen nicht verschlucken (aeltere Proxy-Version
// liefert gar kein section-Feld -> die landen oben unter 'tier').
const known = new Set(GROUPS.map(g => g[0]));
const rest = _modelListCache.filter(m => !known.has(m.section || 'tier'));
for (const m of rest) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.displayName || m.id;
@@ -7694,6 +8085,10 @@
opt.value = ''; opt.textContent = '(keine Modelle vom Proxy)';
sel.appendChild(opt);
}
if (st && d.catalogOk === false) {
st.textContent = 'Anthropic-Katalog nicht erreichbar — nur die automatischen Tiers verfügbar.';
st.style.color = '#FFD60A';
}
onModelSelectChange();
// Aktuelles Model vom Brain holen → markiert die richtige Option
loadModel();
@@ -7702,6 +8097,95 @@
}
}
// ── Datenschutz-Schalter ──────────────────────────────────────────
function loadPrivacyFlags() {
const st = document.getElementById('privacy-flags-status');
if (st) { st.textContent = 'Lese den Proxy-Container …'; st.style.color = '#8888AA'; }
send({ action: 'get_privacy_flags' });
}
function savePrivacyFlags() {
const flags = {};
document.querySelectorAll('#privacy-flags-list input[type=checkbox][data-key]')
.forEach(cb => { flags[cb.dataset.key] = cb.checked; });
if (!Object.keys(flags).length) {
const st = document.getElementById('privacy-flags-status');
if (st) { st.textContent = 'Erst „Prüfen" drücken.'; st.style.color = '#FFD60A'; }
return;
}
send({ action: 'set_privacy_flags', flags });
}
function renderPrivacyFlags(msg) {
const box = document.getElementById('privacy-flags-list');
const st = document.getElementById('privacy-flags-status');
if (!box) return;
if (msg.error) {
box.innerHTML = '';
if (st) { st.textContent = 'Fehler: ' + msg.error; st.style.color = '#FF6B6B'; }
return;
}
box.innerHTML = (msg.flags || []).map(f => {
// "container" = per docker-compose gesetzt. Das gewinnt gegen
// settings.json, also waere ein Haken hier wirkungslos — darum sperren
// und ehrlich sagen warum.
const locked = f.source === 'container';
return '<label style="display:flex;gap:10px;align-items:flex-start;cursor:' + (locked ? 'default' : 'pointer') + ';">'
+ '<input type="checkbox" data-key="' + f.key + '"' + (f.disabled ? ' checked' : '')
+ (locked ? ' disabled' : '') + ' style="margin-top:3px;">'
+ '<span style="flex:1;">'
+ '<span style="color:' + (f.disabled ? '#3FFF9F' : '#FFD60A') + ';font-size:12px;">'
+ (f.disabled ? '✓ ' : '⚠ ') + escapeHtml(f.label) + '</span>'
+ '<span style="color:#555570;font-size:10px;"> — ' + escapeHtml(f.key) + '</span>'
+ '<br><span style="color:#8888AA;font-size:10px;">' + escapeHtml(f.info) + '</span>'
+ (locked ? '<br><span style="color:#FF9F0A;font-size:10px;">Per docker-compose gesetzt — '
+ 'das gewinnt gegen diese Einstellung und lässt sich hier nicht ändern.</span>' : '')
+ '</span></label>';
}).join('') || '<span style="color:#555570;font-size:11px;">(keine Angaben)</span>';
const offen = (msg.flags || []).filter(f => !f.disabled).length;
if (st) {
if (msg.bulkDisabled) {
st.textContent = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC ist gesetzt — das schaltet ohnehin alles ab.';
st.style.color = '#3FFF9F';
} else if (offen === 0) {
st.textContent = '✅ Alle drei Kanäle sind zu.';
st.style.color = '#3FFF9F';
} else {
st.textContent = '⚠ ' + offen + ' von ' + (msg.flags || []).length + ' Kanälen senden noch. '
+ 'Haken setzen und speichern.';
st.style.color = '#FFD60A';
}
}
}
/**
* Testet ein Modell mit einer echten Mini-Anfrage.
* fromFreeText=true nimmt die handgetippte ID, sonst das Dropdown.
*
* Was hier NICHT passiert: den Dropdown-Wert zurueckgeben. Angezeigt wird,
* was die CLI als tatsaechlich benutztes Modell meldet — bei einem Alias
* also die aufgeloeste Version, bei einem Tippfehler der Fehler.
*/
function testModel(fromFreeText) {
const model = fromFreeText
? (document.getElementById('setting-model')?.value || '').trim()
: (document.getElementById('setting-model-select')?.value || '');
const box = document.getElementById('model-test-result');
if (!box) return;
box.style.display = 'block';
if (!model) {
box.textContent = 'Kein Modell ausgewählt.';
box.style.color = '#FF6B6B';
return;
}
box.textContent = `Teste "${model}" — schicke eine echte Anfrage …`;
box.style.color = '#8888AA';
const btn = document.getElementById('btn-test-model');
if (btn && !fromFreeText) { btn.disabled = true; btn.textContent = '🔬 läuft …'; }
send({ action: 'test_model', model });
}
function onModelSelectChange() {
const sel = document.getElementById('setting-model-select');
const desc = document.getElementById('model-desc');
+301 -3
View File
@@ -268,6 +268,9 @@ const RUNTIME_CONFIG_FIELDS = [
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
"brainModel",
// 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 = {
@@ -2201,22 +2204,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) }));
@@ -3207,6 +3215,18 @@ 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 === "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)}`;
@@ -3629,6 +3649,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 ───────────────────────────────────────────────
+2 -2
View File
@@ -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
@@ -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")
@@ -368,7 +403,12 @@ class RvsClient(
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
return runCatching { voice.requestTts(text); Unit }
// 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 {
@@ -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 {
@@ -191,15 +192,39 @@ class VoiceSession(
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
send("tts_request", JSONObject().apply {
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")
@@ -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" />
+1 -1
View File
@@ -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
+31 -8
View File
@@ -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";
}
+29 -11
View File
@@ -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
View File
@@ -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,
})),
});
}
+85 -26
View File
@@ -99,6 +99,12 @@ STREAM_SPEAKER_CHECK_MS = 1500 # Mindest-Audio fuer Speaker-ID-Pruefung
STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint
STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
# Zwischen-Transkriptionen sehen nur die letzten N Sekunden an. Sonst waechst
# ihre Rechenzeit mit der Aufnahmelaenge (jedes Mal der ganze Buffer): bei 2min
# Sprechen dauerte eine Zwischen-Transkription mehrere Sekunden, blockierte die
# GPU und machte die Live-Anzeige traege. Der FINALE Text entsteht weiterhin aus
# dem gesamten Buffer (beam_size=5) — es geht also kein Wort verloren.
STREAM_PARTIAL_WINDOW_MS = 30000
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
# Akustisches Endpointing (ergaenzt die rein-semantische Stagnation).
@@ -387,6 +393,10 @@ class StreamSession:
speaker_checked: bool = False
speaker_match: Optional[bool] = None
speaker_similarity: float = 0.0
# Laufende Zwischen-Transkription. Die laeuft NEBENHER, nicht im Tick —
# sonst stand der Endpointer waehrend des Transkribierens, und die
# Rechenzeit wurde als Stille gezaehlt (siehe _tick_session).
partial_task: Optional[asyncio.Task] = None
class SessionManager:
@@ -479,7 +489,11 @@ class SessionManager:
return session
def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None)
sess = self._sessions.pop(request_id, None)
# Nicht als Waise weiterlaufen lassen — sie haelt sonst den GPU-Lock
# fuer eine Sitzung, die es nicht mehr gibt.
if sess is not None and sess.partial_task is not None and not sess.partial_task.done():
sess.partial_task.cancel()
async def run_endpointer(self) -> None:
"""Background-Loop: alle ~200ms ueber alle Sessions iterieren."""
@@ -660,38 +674,78 @@ class SessionManager:
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
return
# Zwischen-Transkription NEBENHER starten, nicht hier abwarten.
#
# WARUM: dieser Tick laeuft alle 200ms und ist die einzige Stelle, die
# akustische Sprach-Energie misst (last_voice_at). Wurde hier auf das
# Transkribieren gewartet, stand der Endpointer solange — und beim
# naechsten Tick war `now - last_voice_at` um die gesamte Rechenzeit
# groesser. Die Rechenzeit zaehlte also als STILLE.
#
# Das skaliert mit der Aufnahmelaenge, weil jede Zwischen-Transkription
# den KOMPLETTEN Buffer neu durchlaeuft: bei 10s Audio harmlos, bei 2min
# dauert sie mehrere Sekunden und reisst damit die endpoint_ms-Grenze —
# mitten im Satz. Genau das war "lange Texte hoeren irgendwann auf,
# obwohl ich weiterrede".
if sess.partial_task is not None and not sess.partial_task.done():
return # vorige laeuft noch — Tick bleibt frei fuers Messen
sess.last_transcribe_at = now
try:
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
except Exception:
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
return
sess.partial_task = asyncio.create_task(self._partial_transcribe(sess, ws))
async def _partial_transcribe(self, sess: StreamSession, ws) -> None:
"""Zwischen-Transkription fuer Live-Text + Wachstums-Erkennung.
Laeuft als eigener Task (siehe _tick_session). Der finale Text entsteht
in _finalize aus dem GESAMTEN Buffer mit beam_size=5 — hier darf also
gekuerzt und ungenau gearbeitet werden, ohne dass Text verloren geht.
"""
try:
# Nur das Ende des Buffers ansehen. Sonst waechst die Rechenzeit mit
# der Aufnahmelaenge, bindet die GPU und die Live-Anzeige wird immer
# traeger. Fuer "waechst der Text noch?" reicht der Schluss.
tail_bytes = int(STREAM_PARTIAL_WINDOW_MS / 1000.0 * sess.sample_rate) * 2
buf = bytes(sess.pcm_buffer)
windowed = len(buf) > tail_bytes
try:
audio = pcm_s16le_to_float32(buf[-tail_bytes:] if windowed else buf)
except Exception:
logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8])
return
# Kleinere beam_size fuer Streaming-Partials — wir wollen Latenz,
# nicht maximale Genauigkeit. Final-Transcribe (in _finalize) faehrt
# dann mit beam_size=5.
text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=1, vad_filter=True)
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
try:
text, _dur = await self.runner.transcribe(
audio, sess.language, beam_size=1, vad_filter=True)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8])
return
text = text.strip()
grew = bool(text) and text != sess.last_partial
if grew:
sess.last_partial = text
sess.last_growth_at = now
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
await _send(ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
})
await _debug_log(ws, "stream.partial",
f"id={sess.request_id[:12]} text={text[:80]!r}")
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
# semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun.
if sess.endpoint_sent or sess.closed:
return # Session ist durch — Partial nicht mehr nachschicken
text = text.strip()
grew = bool(text) and text != sess.last_partial
if grew:
sess.last_partial = text
sess.last_growth_at = time.time()
# Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's
# mit damit Diagnostic / ChatScreen Live-Text zeigen kann.
await _send(ws, "stt_partial", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": text,
# Nur der Schluss der Aufnahme — der volle Text kommt final.
"windowed": windowed,
})
await _debug_log(ws, "stream.partial",
f"id={sess.request_id[:12]}{' [tail]' if windowed else ''} text={text[:80]!r}")
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
# semantischer Backstop) laeuft im Tick, hier nichts mehr zu tun.
except asyncio.CancelledError:
pass
def _buffer_duration_ms(self, sess: StreamSession) -> float:
# 16-bit s16le mono → 2 bytes pro Sample
@@ -743,6 +797,11 @@ class SessionManager:
if sess.endpoint_sent:
return
sess.endpoint_sent = True
# Laufende Zwischen-Transkription abbrechen: sie haelt den GPU-Lock und
# der finale Text muesste sonst hinter ihr warten — bei langen Aufnahmen
# mehrere Sekunden zusaetzliche Verzoegerung fuer nichts.
if sess.partial_task is not None and not sess.partial_task.done():
sess.partial_task.cancel()
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
if audio.size == 0:
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])