Compare commits

..

19 Commits

Author SHA1 Message Date
duffyduck 886b4409d2 release: bump version to 0.1.2.0 2026-06-02 15:22:02 +02:00
duffyduck bcea49365d feat(filemanager): 👁 Open + ⬇ Download pro Datei in App + Diagnostic
Stefan's UX-Wunsch: Datei direkt oeffnen ohne Umweg ueber Download.
Plus: in der App fehlte komplett der Per-Row-Download-Button (nur via
Checkbox + Bulk-Download). Beides jetzt gefixt.

App (SettingsScreen.tsx):
  - Neue per-Row-Buttons: 👁 Open + ⬇ Download + 🕒 Versionen + 🗑 Loeschen
  - Open-Pfad nutzt requestId-Praefix 'open-' im file_response-Handler
    → Datei wird nach CachesDirectory geschrieben (kein Storage-Bloat)
    → FileOpener-Native-Module (Intent.ACTION_VIEW mit MIME) oeffnet
      mit dem System-Picker → User waehlt PDF-Viewer / Galerie / Player
  - guessMimeFromName-Helper fuer den Intent damit Android die passende
    App findet
  - Download-Pfad unveraendert ('single-' Praefix), schreibt nach
    DownloadDirectory mit Suffix-Inkrement bei Namens-Konflikt

Diagnostic (server.js + index.html):
  - Neue Route /api/files-view (gleicher Code-Pfad wie files-download,
    aber Content-Disposition:inline + echter MIME-Type statt octet-stream)
  - Browser zeigt PDF / Bilder / Text im neuen Tab statt forcierten Download
  - 👁-Button in jeder File-Row neben ⬇/🕒/🗑
  - Fallback fuer unbekannte MIMEs: octet-stream → Browser bietet Download

Bei beiden Pfaden bleibt der Cache nutzbar: nach App-Open kann der User
die Datei im jeweiligen Viewer behalten; im Browser bleibt sie im Tab.
2026-06-02 14:55:24 +02:00
duffyduck 05eb7ed144 fix(whisper): Halluzinations-Filter — kein 'Untertitelung des ZDF' bei Stille
Stefan-Reproduktion: nach Wake-Word + ARIA-Antwort oeffnet das
Conversation-Window automatisch das Mikro fuer Follow-Up. Wenn Stefan
nichts sagt, ist das 4-8s Stille. Whisper halluziniert dann YouTube-
Untertitel-Patterns aus seinem Trainings-Corpus — gemessen 'Untertitelung
des ZDF, 2020' — und ARIA antwortet brav darauf. Endlos-Loop bis Stefan
manuell stoppt.

Fix in faster-whisper-transcribe:

1. Per-Segment no_speech_prob auswerten. Bei >= 0.6 (relativ konservativ:
   echte leise Sprache geht noch durch) → Segment verwerfen. Das eliminiert
   die offensichtlichen Halluzinationen schon zu 90%.

2. Bekannte Hallucination-Phrasen-Blacklist:
     - Untertitelung/Untertitel des ZDF (mit/ohne Jahr)
     - Amara.org community
     - Vielen Dank fuer's Zuschauen (mit allen Umlaut/Apostroph-Varianten)
     - Thanks for watching / Subs by ...
   Substring-Match (case-insensitive) auf normalisiertem Text (lowercase,
   Trailing-Punctuation und Jahres-Suffix '2020' weg).

3. Wenn ALLE Segmente einer Aufnahme rausgefiltert werden, ist text=''
   → App behandelt das via existierende no-speech-Pfad: Conversation-
   Window endet sauber, kein TTS-Echo-Loop.

Tradeoff: echte Phrasen wie 'Vielen Dank' allein gehen durch (Pattern
ist 'vielen dank fuer's zuschauen' — voller Match). Nur die bekannten
Halluzinations-Phrasen werden weggefiltert.

Falls in Zukunft neue Patterns auftauchen (Whispers Modell ändert sich):
einfach _HALLUCINATION_PHRASES erweitern, kein Brain-Restart noetig (lebt
in der Whisper-Bridge, die hot-reloaded werden kann).
2026-06-02 14:19:22 +02:00
duffyduck ddfc4261e5 fix(diagnostic): Versions-Liste dedupliziert via Blob-Hash — keine Restore-Duplikate
Stefan-Beobachtung: Wenn man V1 restored, taucht der neue Restore-Commit
als V4 in der Liste auf, mit identischem Inhalt wie V1. Bei mehrfachem
Hin- und Herrestoren wird die Liste schnell unuebersichtlich.

Fix: listVersionsForFile dedupliziert auf Blob-Hash-Ebene. Pro
inhaltlich identischer Datei-Variante wird nur der AELTESTE (= zuerst
in der History entstandene) Commit gezeigt. Restore-Commits werden
damit gefiltert da ihr Blob = der Blob eines aelteren Commits ist.

AKTIV-Marker wandert mit: vergleicht Blob der Working-Copy mit jedem
sichtbaren Eintrag — der Match-Eintrag bekommt isCurrent=true. So
zeigt das UI nach Restore "V1 ist AKTIV" obwohl im git ein neuer
V4-Commit existiert.

Implementation:
  - log --format=%H + ls-tree pro Commit → blob-hash sammeln
  - rueckwaerts durchgehen (chronologisch aelteste zuerst), seen-Set
    dedupliziert
  - Reverse fuer UI (neueste-zuerst)
  - git hash-object <working-copy> → currentBlob, mit jedem Eintrag
    vergleichen fuer den AKTIV-Marker
  - blob-Feld aus Response strippen (sieht aus wie zweite Commit-ID)

Audit-Trail bleibt im git intakt — Restore-Commits sind weiterhin
da, nur nicht im UI sichtbar. Falls jemals forensische Untersuchung
noetig: `git log` im /shared/uploads zeigt alle, inkl. Restore-Commits.
2026-06-02 13:59:42 +02:00
duffyduck 20e623dc37 feat(app): Versions-Historie pro Datei im App-Datei-Manager
Step 3 vom File-Versioning-Feature (Step 1+2 lief schon in Diagnostic).
App kann jetzt:
  - pro Datei via 🕒-Button die Versions-Liste anzeigen
  - alte Versionen als '<name>@<short-hash>.<ext>' nach Downloads schreiben
  - per ⟲ eine alte Version als neue aktive setzen (non-destructive,
    macht im Backend einen 'restore:'-Commit, die bisherige Version
    bleibt in der Historie)

Drei neue RVS-Message-Type-Paare:
  file_version_list_request    / _response
  file_version_download_request / _response (base64)
  file_version_restore_request  / _response

rvs/server.js: alle sechs Typen in die ALLOWED_TYPES-Whitelist.

bridge/aria_bridge.py: handler proxen die Anfragen an diagnostic
(http://localhost:3001/api/files-versions / -version-content / -restore).
Diagnostic ist eh schon der Owner der git-Repository-Logik. Bridge
wrappt die Binary-Antwort als base64 fuer den RVS-Transport.

android/src/screens/SettingsScreen.tsx:
  - State versionsOpen/versionsList/-Loading/-Error
  - Drei rvs.onMessage-Branches fuer die neuen *_response Types
  - 🕒-Button in jeder Datei-Zeile (zwischen Auswahl-Checkbox und Mülltonne)
  - Neues Modal mit Versions-Liste (AKTIV-Badge, short-hash, subject,
    formatiertes Datum, ⬇ Download + ⟲ Restore-Button pro Eintrag)
  - Restore-Button hat Confirm-Alert
  - Bei file_version_restore_response: list refresh + file-list refresh
2026-06-02 13:50:35 +02:00
duffyduck 6464dbe28c feat(diagnostic): Auto-Versionierung fuer /shared/uploads/ + Versions-UI
Stefan-Wunsch: ARIA-Aenderungen an Dateien sollen vom System (nicht
von ARIA selbst) automatisch versioniert werden. Im Datei-Manager:
Versionen auflisten, einzelne downloaden, oder als neue aktive Version
setzen (Restore = non-destructive neuer Commit).

Implementierung (alles im diagnostic-Container, da der eh schon
File-Handling kann):

1. Dockerfile: apk add git

2. server.js — Auto-Commit-Loop:
   - Beim Start: /shared/uploads als git-Repo initialisieren (idempotent;
     bestehendes .git wird uebernommen)
   - setInterval(30s): git status --porcelain → wenn dirty, add+commit
     mit "auto: <ISO-Timestamp>"-Message
   - Re-Entrancy-Guard fuer langsame git-Ops

3. server.js — drei neue HTTP-Routen:
   GET  /api/files-versions?path=X
     → [{hash, ts, subject, isCurrent}] aus git log --follow
   GET  /api/files-version-content?path=X&hash=Y
     → Binary-Stream der Datei aus diesem Commit (Content-Disposition
       attachment mit "name@<short-hash>.ext" als Default-Dateiname)
   POST /api/files-version-restore body={path, hash}
     → non-destructive: schreibt alten Inhalt als NEUE Version, neuer
       Commit "restore: <path> <- <short>". Aktive Version damit
       weiterhin rollback-bar.

4. index.html — Datei-Manager:
   - Pro Datei zusaetzlich 🕒-Button neben ⬇/🗑
   - Klick zeigt Modal mit Version-Liste (timestamp, short-hash,
     'AKTIV'-Marker fuer den jeweils letzten)
   - Pro Version: ⬇ Download + ⟲ Restore (mit Confirm)
   - Restore broadcasted file_version_restored damit Browser refreshen

Path-Safety: alle Pfade muessen relative-to-uploads sein, kein '..',
kein '/', kein '.git/'. Hash muss [0-9a-f]{7,40}.

.gitignore zunaechst keine — uploads/ ist eh nur User-/ARIA-Dateien,
kein Log-Noise erwartet. Falls Disk explodiert: spaeter ergaenzen.

Step-2 (App-Side via RVS-Messages) folgt im naechsten Commit, sobald
das hier in Diagnostic funktioniert.
2026-06-02 09:25:06 +02:00
duffyduck c38e1b197b release: bump version to 0.1.9.0 2026-06-01 18:26:17 +02:00
duffyduck 7a05e8233c debug(audio): RVS-Logs in _firePlaybackStarted + _releaseFocusDeferred
Stefan testet Spotify-Resume nach TTS, klappt noch nicht. Aktuell sehe ich
in den App-Logs nur 'PcmPlaybackFinished native event' aber NICHT ob
requestDuck / release / nudgeMediaResume jemals laufen.

Logge jetzt:
  audio.focus: 'TTS-start: requestDuck() called + canceled pending release'
  audio.focus: '_releaseFocusDeferred SKIPPED (conversation active)' (skip case)
  audio.focus: '_releaseFocusDeferred scheduled in 800ms'
  audio.focus: 'release timer fired but conversation now active → SKIP' (race)
  audio.focus: 'AudioFocus.release() now'
  audio.focus: 'nudgeMediaResume() now (50ms after release)'

Damit beim naechsten Stefan-Test eindeutig zuordenbar wo der Resume-
Pfad genau klemmt — feuern beide native Calls? Werden sie geskipped?
Greift der Cancel zu frueh? etc.
2026-06-01 11:44:11 +02:00
duffyduck 73d5bbd7be fix(proxy): Prompt an claude-CLI via stdin statt argv — fix Bad Gateway durch E2BIG
Wurzel: claude-max-api-proxy's subprocess/manager.js passt den Prompt
als letztes CLI-Argument an spawn('claude', [...args, prompt]). Bei
ARIA's groesseren Prompts (52 Messages + 24 Tools = ~80-100 KB) ueber-
schreitet das den Linux-Kernel-Limit fuer Argument-Listen → spawn
wirft E2BIG → Proxy gibt 500 zurueck → Brain wirft 502 → aria-bridge
wrappt als '[Brain-Fehler] HTTP Error 502: Bad Gateway' und sendet
das als Chat-Bubble + TTS. Stefan sieht 'Bad Gateway' und die App
spricht das auch noch aus.

Fix per zwei zusaetzlichen sed-Patches in docker-compose.yml die beim
Proxy-Start neben den bestehenden ausgefuehrt werden:

  1. Loescht die 'prompt, // Pass prompt as argument'-Zeile aus
     buildArgs() — claude-CLI bekommt den Prompt nicht mehr per argv
  2. Aendert this.process.stdin?.end() in start() zu
     this.process.stdin?.end(prompt) — Prompt wird nach Spawn via
     stdin geschrieben und stdin sofort danach geschlossen

Test: 'echo "test" | claude --print' funktioniert sauber. Stdin hat
kein Limit wie argv (E2BIG). Original-Kommentar 'more reliable than
stdin' war wohl von einer alten CLI-Version — aktuelles claude-CLI
reads stdin in --print mode korrekt.

Idempotent: beim Container-Restart sind die seds no-op (gemusterter
Code schon nicht mehr da).

Bonus-Wert: claude-max-api-proxy npm package muss man nicht patchen,
unsere Aenderungen ueberleben package-Updates (sed im compose-command).
2026-06-01 08:16:13 +02:00
duffyduck da38cdfefa release: bump version to 0.1.8.9 2026-05-31 23:54:23 +02:00
duffyduck 9c0c13d1f6 fix(audio): expliziter AudioFocus-Request beim TTS-Start — Spotify resumed wieder zuverlaessig
Stefan-Symptom: Spotify pausiert wenn ARIA zuhoert/spricht ✓, aber
resumed nach TTS-Ende NICHT (oder nur unzuverlaessig).

Diagnose aus dem Log-Trace:

  Manual-Button-Flow:
    recording start → AudioFocus.requestExclusive (Spotify pausiert)
    STT-Endpoint    → _cleanupStreamLocal → _releaseFocusDeferred → 800ms
                      Timer → release+nudge (Spotify wuerde resumen)
    Brain processing 50s lang...
    TTS-Start       → KEIN expliziter Focus-Request! AudioTrack-USAGE_
                      ASSISTANT pausiert Spotify nur IMPLIZIT (versions-
                      abhaengig). Wir wissen nicht ob Spotify gerade
                      gepaust ist.
    TTS-End         → release+nudge — aber wenn Spotify implizit-paused
                      ist, hat es keinen sauberen Focus-Owner gesehen
                      und nudge alleine reicht nicht zum Resume.

  Wake-Word-Flow:
    Aehnliches Problem wenn state schon armed ist beim TTS-Ende
    (vom 'no-speech in conv-window'-Pfad), dann ist mein
    endConversation ein noop → releaseConversationFocus laeuft nie,
    nur die PcmPlaybackFinished-Direkt-Release greift, hat aber
    dasselbe nudge-zu-schwach-Problem.

Fix in _firePlaybackStarted (audio.ts): EXPLIZITES AudioFocus.requestDuck
beim ersten TTS-PCM-Chunk. Damit IST Spotify ueber unseren Focus
gepaused, statt nur implizit. Der spaetere release beim Pcm-Playback-
Finished ist dann das normale 'Owner ist fertig'-Pattern das Spotify
zuverlaessig zum Resume triggert.

Idempotenz: requestDuck released vorher den vorigen Focus (in Kotlin),
also harmlos wenn wake-word-acquireConversationFocus eh schon requestDuck
gerufen hat. Plus _cancelDeferredFocusRelease vorne, damit kein noch
pendender 800ms-Timer mitten in der TTS Spotify falsch resumed.

Stefan testet im Auto und auf dem Tisch — beide Spotify-Versionen
sollten mit explizitem Focus-Owner-Wechsel sauber pausieren+resumen.
2026-05-31 23:51:43 +02:00
duffyduck ba26fa5880 feat(voice): ARIA produziert TTS-sprechbare Variante via <voice>-Tag
Stefan-Beobachtung: Wetterbericht klang scheisse, weil 'kt'/'°C'/Komma-
Zahlen literal vorgelesen wurden. Der clean_text_for_tts-Regex kennt nur
Markdown + ein paar Uhrzeit-Patterns — Einheiten ausschreiben war never
on the table mit Regex (waechst sonst zur Mammut-Tabelle).

Loesung: hybrid. ARIA selbst macht die semantisch korrekte TTS-Variante,
Regex bleibt als Safety-Net.

bridge/aria_bridge.py:
  - neue Helper strip_voice_tag_for_display(text)
  - chat-broadcast (1188) und chat_backup-Persist (1157) strippen den Tag
    BEVOR Output. ARIA's `<voice>...</voice>` lebt nur transient in `text`
    bis clean_text_for_tts ihn fuer TTS extrahiert.
  - clean_text_for_tts unveraendert (kennt den Tag schon seit Phase 1).

aria-brain/seed_rules.py:
  - neue seed-rule voice/tts-voice-tag mit klarer Trigger-Liste
    (Einheiten, Komma-Zahlen, Uhrzeiten, Statusberichte, lange IDs/Codes)
  - klare Anti-Trigger (kurze 'OK'/'mache ich'-Antworten — kein doppelter
    Text fuer Trivia)
  - drei konkrete Beispiele (Wetter, Uhrzeit, Server-Status, Music-Now-Playing)

Output-Format: ARIA schreibt erst Chat-Display-Variante (mit Markdown OK),
haengt dann an EINER neuen Zeile den <voice>-Block an. Tag wird automatisch
gestrippt fuer App-Anzeige + Chat-Backup. f5tts kriegt nur den voice-Inhalt.

Beide Welten happy: Stefan sieht hervorgehobenes Markdown in der Bubble,
hoert sprechbar formulierten Text aus dem Lautsprecher. Keine wachsende
Regex-Tabelle mehr.

Deploy: brain rebuild + restart, bridge restart.
2026-05-31 01:20:17 +02:00
duffyduck 027ba2896d fix(brain): dritter Tool-Result-Truncation-Punkt — agent.py:947 von 8KB auf 50KB
Im Stefan-Test (31.05.2026) hat ARIA das spotify-Skill korrekt mit
_all=true aufgerufen, der Skill paginierte sauber alle 90 Playlists
in 34 KB compact JSON. Aber: ARIA's Thinking sagte 'tool result was
cut off'. Disk-Log + Skill-Return waren beide OK. agent.py:1160 cap
ist 50 KB (passt). Aber:

agent.py:947 cap'd tool_result[:8000] beim Append in die Proxy-
Conversation. DRITTER Truncation-Punkt fuer denselben Output, fuer
Claude effectiv abgeschnitten — Skill liefert 34 KB, ARIA sieht 8 KB.

Auf 50 KB hochgesetzt (konsistent mit dem anderen Cap in derselben
Datei). Tests + nochmal restart noetig.

Lerneffekt: bei Stdout-Caps suche IMMER alle Truncation-Punkte. War
jetzt der dritte den ich heute gefunden hab. ;-)
2026-05-31 01:03:20 +02:00
duffyduck 86f20d3b64 clude config 2026-05-31 00:19:54 +02:00
duffyduck 78211f09ce feat(brain): Listen-API-Pagination strukturell loesen + seed-rule
Stefan-Reproduktion vom 31.05.2026: bei 'Such Playlist Prodigy raus'
hat ARIA die Spotify-Pagination drei Mal hintereinander laufen lassen,
jedes Mal eine andere Playlist-ID gefunden, am Ende falsche abgespielt.
Spotify sortiert /v1/me/playlists nach recently-played — die Reihen-
folge aendert sich zwischen Calls wenn parallel was laeuft, also
liefern aufeinanderfolgende paginierte Runs inkonsistente Snapshots.

Loesungen:

1. **spotify-Skill _all=true** (via skill_update angewendet, lebt nur
   in /data/skills/spotify/ im Container, nicht in git): Skill prueft
   _all=true im URL-Query, paginiert dann intern ueber Spotifys
   next-Field bis MAX_PAGES (20) oder fertig. Liefert konsolidiertes
   JSON {items, total, fetched_count, fetched_pages}. EIN Tool-Call,
   konsistenter Snapshot.

2. **skills.py: Stdout-Truncation entkoppeln**. Vorher: 8000-char-Cap
   sowohl fuer Disk-Log als auch fuer Return-Value an Agent.
   Konsequenz: _all=true Output (50 KB JSON) wurde fuer ARIA auf 8 KB
   gekuerzt, sie sah nur die ersten ~20 Playlists. Jetzt:
     - Disk-Log: weiterhin 8 KB pro stdout (Disk-Schoner)
     - Return-Value: ungekuerzt, agent.py macht 50 KB downstream-Cap
   Skills.py:687 — record-Dict aufgesplittet in log_record + record.

3. **seed_rule list-api-pagination-snapshot**: dokumentiert das
   Pattern fuer ARIA — bei Pagination-Resultaten IMMER vollstaendig
   laden bevor Entscheidung; _all=true bevorzugen wo verfuegbar;
   bei inkonsistenten Match-Resultaten ehrlich nachfragen statt
   raten. Mit konkreter Antipattern-Sammlung aus Stefans Test.

Deployment: brain restart noetig damit (2) und (3) greifen. Skill-
Code (1) ist schon via PATCH /skills/spotify aktiv.
2026-05-31 00:14:06 +02:00
duffyduck b2edee9adb release: bump version to 0.1.8.8 2026-05-30 23:32:27 +02:00
duffyduck bb13477ef9 fix(wake): Race zwischen endConversation und stopBargeListening killt
Wake-Word-Listener nach jeder Konversation

Aus dem Log diagnostiziert: zwei onPlaybackFinished-Listener feuern
direkt hintereinander wenn TTS endet:
  1. mein neuer Listener (Background): endConversation()
     → state=armed, OpenWakeWord.start() (idempotent)
  2. existierender Listener:           stopBargeListening()
     → bargeListening=true → OpenWakeWord.stop()  ← killt re-armed Listener

State zeigte 'armed' (UI: Ohr-Icon ausgefuellt, sieht aktiv aus), aber
das Native-Modul war gestoppt → Stefan's "Computer" verpufft.

Fix: endConversation setzt bargeListening=false BEVOR Native gerufen
wird. stopBargeListening checkt das Flag oben:
  async stopBargeListening() { if (!this.bargeListening) return; ... }
→ wird zum No-Op wenn endConversation schon gelaufen ist.

Bonus: OpenWakeWord.start() darf jetzt auch gerufen werden wenn der
Listener via barge-listening schon lief — Kotlin checkt running.get()
und resolved idempotent. Sicherer als state-vorher-Check.
2026-05-30 23:31:25 +02:00
duffyduck 710e7c88d8 release: bump version to 0.1.8.7 2026-05-30 23:23:52 +02:00
duffyduck b6ee5552f0 fix(app): Dateimanager Einzel-Download landet jetzt im Downloads-Ordner
Bug: '⬇ Download' im Dateimanager schickte file_request raus, aber kein
SettingsScreen-Handler nahm das file_response auf. ChatScreen fing es
zwar global ab, versuchte aber nur Chat-Bubble-Attachments zu
patchen — kein Match, also passierte sichtbar nichts.

Fix: Handler in SettingsScreen fuer file_response mit requestId-Praefix
'single-' (aus bulkDownload-1-Datei-Pfad). Schreibt nach
RNFS.DownloadDirectoryPath, mit Suffix-Inkrement bei Namens-Konflikt
damit nichts ueberschrieben wird.

Multi-Datei-Download (ZIP) lief schon ueber file_zip_response,
unangetastet.
2026-05-30 23:22:44 +02:00
16 changed files with 1131 additions and 23 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ssh root@172.0.2.33 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
]
}
}
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10806 versionCode 10200
versionName "0.1.8.6" versionName "0.1.2.0"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.1.8.6", "version": "0.1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+309
View File
@@ -21,9 +21,37 @@ import {
PermissionsAndroid, PermissionsAndroid,
useWindowDimensions, useWindowDimensions,
DeviceEventEmitter, DeviceEventEmitter,
NativeModules,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs'; import RNFS from 'react-native-fs';
const { FileOpener } = NativeModules as {
FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
};
// MIME-Type aus Dateinamen schaetzen — fuer den FileOpener-Intent. Android
// nutzt den MIME-Type um die passende App zu finden. Unknown → octet-stream.
function guessMimeFromName(name: string): string {
const lower = name.toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.mp3')) return 'audio/mpeg';
if (lower.endsWith('.wav')) return 'audio/wav';
if (lower.endsWith('.ogg') || lower.endsWith('.opus')) return 'audio/ogg';
if (lower.endsWith('.mp4') || lower.endsWith('.m4a')) return 'audio/mp4';
if (lower.endsWith('.webm')) return 'video/webm';
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.md')) return 'text/markdown';
if (lower.endsWith('.json')) return 'application/json';
if (lower.endsWith('.csv')) return 'text/csv';
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
if (lower.endsWith('.zip')) return 'application/zip';
return 'application/octet-stream';
}
import DocumentPicker from 'react-native-document-picker'; import DocumentPicker from 'react-native-document-picker';
import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs'; import rvs, { ConnectionState, RVSMessage, ConnectionConfig, ConnectionLogEntry } from '../services/rvs';
import { import {
@@ -180,6 +208,14 @@ const SettingsScreen: React.FC = () => {
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set()); const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
const [fileZipBusy, setFileZipBusy] = useState(false); const [fileZipBusy, setFileZipBusy] = useState(false);
// Versions-Modal — pro Datei eine kleine Historie aus dem auto-commit-git
// im diagnostic-Container. Browser-Variante davon laeuft schon, hier App-
// Side via RVS-Messages (file_version_list_request/...).
const [versionsOpen, setVersionsOpen] = useState<{name: string; path: string} | null>(null);
const [versionsList, setVersionsList] = useState<Array<{hash: string; ts: number; subject: string; isCurrent?: boolean}>>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsError, setVersionsError] = useState('');
const versionDlPending = useRef<string | null>(null); // requestId beim Versions-Download
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false); const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState(''); const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs. // Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
@@ -497,6 +533,137 @@ const SettingsScreen: React.FC = () => {
})(); })();
} }
// Datei-Manager: Einzel-Datei-Download. ChatScreen subscribet auch auf
// file_response — der versucht aber nur Chat-Bubble-Attachments zu
// patchen und macht nix wenn die requestId nicht zu einer Nachricht
// passt. Hier behandeln wir die Manager-initiierten Downloads
// (requestId-Praefix 'single-' aus bulkDownload). Schreibt nach
// ~/Download/ wie der ZIP-Pfad.
if (message.type === ('file_response' as any)) {
const p: any = message.payload || {};
const reqId = (p.requestId as string) || '';
const isDownload = reqId.startsWith('single-');
const isOpen = reqId.startsWith('open-');
if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.)
if (p.error) {
ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return;
}
const b64 = (p.base64 as string) || '';
if (!b64) return;
const fileName = (p.name as string) ||
(p.serverPath as string || '').split('/').pop() ||
'aria-download';
(async () => {
try {
if (isOpen) {
// Open-Pfad: nach Caches schreiben + per FileOpener mit System-
// Viewer oeffnen. Caches damit der Speicher kein Dauer-Muell wird.
const dir = RNFS.CachesDirectoryPath;
const target = `${dir}/${fileName}`;
await RNFS.writeFile(target, b64, 'base64');
const mime = (p.mimeType as string) || guessMimeFromName(fileName);
if (FileOpener?.open) {
try {
await FileOpener.open(target, mime);
} catch (e: any) {
ToastAndroid.show('Öffnen fehlgeschlagen: ' + (e?.message || e), ToastAndroid.LONG);
}
} else {
ToastAndroid.show('FileOpener-Modul nicht verfügbar — APK neu bauen', ToastAndroid.LONG);
}
return;
}
// Download-Pfad: nach Downloads-Ordner schreiben, mit Suffix bei
// Namens-Konflikt damit nichts ueberschrieben wird.
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`;
let target = filePath;
let i = 1;
while (await RNFS.exists(target)) {
const dot = fileName.lastIndexOf('.');
const base = dot > 0 ? fileName.slice(0, dot) : fileName;
const ext = dot > 0 ? fileName.slice(dot) : '';
target = `${dir}/${base} (${i})${ext}`;
i++;
}
await RNFS.writeFile(target, b64, 'base64');
const sizeKb = Math.round(((b64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Datei-Manager: Versions-Liste einer Datei
if (message.type === ('file_version_list_response' as any)) {
const p: any = message.payload || {};
setVersionsLoading(false);
if (!p.ok) {
setVersionsError(p.error || 'Unbekannter Fehler');
setVersionsList([]);
} else {
setVersionsError('');
setVersionsList(p.versions || []);
}
}
// Datei-Manager: Versions-Inhalt (Download einer alten Version)
if (message.type === ('file_version_download_response' as any)) {
const p: any = message.payload || {};
if (p.requestId && p.requestId !== versionDlPending.current) return;
versionDlPending.current = null;
if (!p.ok) {
ToastAndroid.show('Download fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
// base64 → Downloads-Ordner. Hash als Suffix damit Original nicht
// ueberschrieben wird wenn beide Versionen nebeneinander vorliegen
// sollen.
(async () => {
try {
const baseName = (p.name as string) || 'aria-version';
const shortHash = (p.hash as string || '').slice(0, 7);
const dot = baseName.lastIndexOf('.');
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
const ext = dot > 0 ? baseName.slice(dot) : '';
const dir = RNFS.DownloadDirectoryPath;
let target = `${dir}/${stem}@${shortHash}${ext}`;
let i = 1;
while (await RNFS.exists(target)) {
target = `${dir}/${stem}@${shortHash}_${i}${ext}`;
i++;
}
await RNFS.writeFile(target, p.base64, 'base64');
const sizeKb = Math.round(((p.base64.length * 0.75)) / 1024);
ToastAndroid.show(`Gespeichert: ${target.split('/').pop()} (${sizeKb} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('Speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Datei-Manager: Restore-Bestaetigung
if (message.type === ('file_version_restore_response' as any)) {
const p: any = message.payload || {};
if (!p.ok) {
ToastAndroid.show('Restore fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
ToastAndroid.show(`Version ${(p.hash || '').slice(0,7)} ist jetzt aktiv`, ToastAndroid.SHORT);
// Versions-Liste neu laden damit der neue restore-Commit auftaucht
if (versionsOpen) {
setVersionsLoading(true);
rvs.send('file_version_list_request' as any, { path: versionsOpen.path });
}
// File-Liste auch refreshen (mtime hat sich geaendert)
if (fileManagerOpen) {
setFileManagerLoading(true);
rvs.send('file_list_request' as any, {});
}
}
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen // Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) { if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string; const name = (message.payload as any).name as string;
@@ -921,6 +1088,44 @@ const SettingsScreen: React.FC = () => {
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')} {fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text> </Text>
</View> </View>
<TouchableOpacity
onPress={() => {
rvs.send('file_request' as any, {
serverPath: f.path,
requestId: 'open-' + Date.now(),
});
ToastAndroid.show('Öffne ' + f.name + '…', ToastAndroid.SHORT);
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:18}}>👁</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
rvs.send('file_request' as any, {
serverPath: f.path,
requestId: 'single-' + Date.now(),
});
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
}}
style={{padding:8}}
>
<Text style={{color:'#34C759', fontSize:18}}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
// path-relativ-zu-uploads = nur der Dateiname,
// weil der File-Manager-Bereich flach ist
setVersionsOpen({name: f.name, path: f.name});
setVersionsList([]);
setVersionsError('');
setVersionsLoading(true);
rvs.send('file_version_list_request' as any, { path: f.name });
}}
style={{padding:8}}
>
<Text style={{color:'#0096FF', fontSize:18}}>🕒</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
Alert.alert( Alert.alert(
@@ -948,6 +1153,110 @@ const SettingsScreen: React.FC = () => {
})()} })()}
</View> </View>
</Modal> </Modal>
{/* Versions-Modal — Historie pro Datei (auto-commit-git im diagnostic) */}
<Modal
visible={versionsOpen !== null}
transparent
animationType="fade"
onRequestClose={() => setVersionsOpen(null)}
>
<TouchableOpacity
style={{flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'center', alignItems:'center'}}
activeOpacity={1}
onPress={() => setVersionsOpen(null)}
>
<TouchableOpacity
activeOpacity={1}
onPress={() => {}}
style={{backgroundColor:'#0D0D1A', borderWidth:1, borderColor:'#1E1E2E', borderRadius:8, width:'90%', maxHeight:'80%'}}
>
<View style={{padding:12, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center'}}>
<Text style={{color:'#E0E0F0', fontSize:13, fontWeight:'bold', flex:1}} numberOfLines={1}>
Versionen {versionsOpen?.name || ''}
</Text>
<TouchableOpacity onPress={() => setVersionsOpen(null)} style={{padding:6}}>
<Text style={{color:'#888', fontSize:14}}></Text>
</TouchableOpacity>
</View>
<ScrollView style={{maxHeight:'85%'}} contentContainerStyle={{padding:8}}>
{versionsLoading && (
<Text style={{color:'#888', textAlign:'center', padding:20}}>Lade...</Text>
)}
{!!versionsError && (
<Text style={{color:'#FF6B6B', padding:20}}>{versionsError}</Text>
)}
{!versionsLoading && !versionsError && versionsList.length === 0 && (
<Text style={{color:'#888', textAlign:'center', padding:20}}>
Noch keine Versions-Historie (Datei kommt erst nach dem nächsten Auto-Commit in den Index).
</Text>
)}
{versionsList.map(v => (
<View key={v.hash} style={{padding:10, borderBottomWidth:1, borderBottomColor:'#1E1E2E', flexDirection:'row', alignItems:'center', gap:8}}>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center', gap:6}}>
{v.isCurrent && (
<View style={{backgroundColor:'#34C75922', paddingHorizontal:6, paddingVertical:1, borderRadius:3}}>
<Text style={{color:'#34C759', fontSize:9}}>AKTIV</Text>
</View>
)}
<Text style={{color:'#0096FF', fontSize:11, fontFamily:'monospace'}}>
{v.hash.slice(0,7)}
</Text>
<Text style={{color:'#888', fontSize:11, flex:1}} numberOfLines={1}>
{v.subject || ''}
</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{new Date(v.ts).toLocaleString('de-DE')}
</Text>
</View>
<TouchableOpacity
onPress={() => {
if (!versionsOpen) return;
const reqId = 'verdl_' + Date.now() + '_' + Math.floor(Math.random()*100000);
versionDlPending.current = reqId;
rvs.send('file_version_download_request' as any, {
path: versionsOpen.path,
hash: v.hash,
requestId: reqId,
});
ToastAndroid.show('Download läuft…', ToastAndroid.SHORT);
}}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22'}}
>
<Text style={{color:'#0096FF', fontSize:11}}></Text>
</TouchableOpacity>
{!v.isCurrent && (
<TouchableOpacity
onPress={() => {
if (!versionsOpen) return;
Alert.alert(
'Version aktiv setzen?',
`Hash ${v.hash.slice(0,7)} wird als neue aktive Version gespeichert.\n\nDie aktuelle Version bleibt in der Historie und kann später ebenfalls wiederhergestellt werden.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Restore', onPress: () => {
rvs.send('file_version_restore_request' as any, {
path: versionsOpen.path,
hash: v.hash,
});
ToastAndroid.show('Restore läuft…', ToastAndroid.SHORT);
}},
],
);
}}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF'}}
>
<Text style={{color:'#fff', fontSize:11}}></Text>
</TouchableOpacity>
)}
</View>
))}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<ScrollView <ScrollView
style={styles.container} style={styles.container}
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
+24
View File
@@ -429,24 +429,34 @@ class AudioService {
private _releaseFocusDeferred(): void { private _releaseFocusDeferred(): void {
if (this._conversationFocusActive) { if (this._conversationFocusActive) {
console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release'); console.log('[Audio] _releaseFocusDeferred: Conversation aktiv → kein Release');
import('./logger').then(m => m.reportAppDebug('audio.focus',
'_releaseFocusDeferred SKIPPED (conversation active)')).catch(()=>{});
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
return; return;
} }
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS); console.log('[Audio] _releaseFocusDeferred: in %dms', this.FOCUS_RELEASE_DELAY_MS);
import('./logger').then(m => m.reportAppDebug('audio.focus',
`_releaseFocusDeferred scheduled in ${this.FOCUS_RELEASE_DELAY_MS}ms`)).catch(()=>{});
this.focusReleaseTimer = setTimeout(() => { this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null; this.focusReleaseTimer = null;
if (this._conversationFocusActive) { if (this._conversationFocusActive) {
console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)'); console.log('[Audio] Focus-Release abgebrochen (Conversation jetzt aktiv)');
import('./logger').then(m => m.reportAppDebug('audio.focus',
'release timer fired but conversation now active → SKIP')).catch(()=>{});
return; return;
} }
console.log('[Audio] AudioFocus jetzt released'); console.log('[Audio] AudioFocus jetzt released');
import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{});
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack // Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei // mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss. // manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist. // 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => { setTimeout(() => {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {}); AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50); }, 50);
}, this.FOCUS_RELEASE_DELAY_MS); }, this.FOCUS_RELEASE_DELAY_MS);
@@ -1518,6 +1528,20 @@ class AudioService {
this.playbackStartTime = Date.now(); this.playbackStartTime = Date.now();
this.currentPlaybackMsgId = this.pcmMessageId; this.currentPlaybackMsgId = this.pcmMessageId;
} }
// AudioFocus EXPLIZIT fuer TTS halten — sonst pausiert Spotify zwar
// beim Recording-requestExclusive, der wird aber 800ms nach STT-Endpoint
// released (Brain-Processing-Gap), und wenn dann TTS startet ist niemand
// mehr Focus-Owner. Spotify pausiert evtl. implizit beim AudioTrack-
// USAGE_ASSISTANT, aber unsere nachtraegliche release+nudge-Sequenz
// kann es dann nicht zuverlaessig wieder anstossen. Mit explizitem
// requestDuck IST Spotify sauber-via-Focus pausiert, und der Release
// beim PcmPlaybackFinished triggert das normale "Owner fertig → resume"-
// Pattern in Spotify — funktioniert versionsunabhaengig.
// Pending Release-Timer canceln damit der nicht mitten in der TTS feuert.
this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {});
import('./logger').then(m => m.reportAppDebug('audio.focus',
'TTS-start: requestDuck() called + canceled pending release')).catch(()=>{});
this.playbackStartedListeners.forEach(cb => { this.playbackStartedListeners.forEach(cb => {
try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); } try { cb(); } catch (e) { console.warn('[Audio] playbackStarted listener err:', e); }
}); });
+20 -5
View File
@@ -344,23 +344,38 @@ class WakeWordService {
/** Konversation beenden — User hat im Window nichts gesagt. /** Konversation beenden — User hat im Window nichts gesagt.
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an). * Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
* Ohne: zurueck zu 'off'. * Ohne: zurueck zu 'off'.
*
* WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
* Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
* feuert direkt danach ein zweiter Listener (stopBargeListening) — der
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
* ist, und unseren frisch re-armierten Listener killen.
*/ */
async endConversation(): Promise<void> { async endConversation(): Promise<void> {
if (this.state !== 'conversing') { if (this.state !== 'conversing') {
// Nicht in conversing — typ. nach App-Resume bevor Streaming endete.
// Trotzdem loggen damit wir's im Diagnostic sehen.
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{}); `endConversation called but state=${this.state} → noop`)).catch(()=>{});
return; return;
} }
const wasBarge = this.bargeListening;
// Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
// stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
// OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
// den Listener stoppen.
this.bargeListening = false;
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, nativeReady=${this.nativeReady}, calling OpenWakeWord.start()`)).catch(()=>{}); `endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
// Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
// OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
// und resolved sofort). Wir koennen es trotzdem rufen — billiger
// als state extra zu fragen, garantiert dass nach diesem Pfad
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
try { try {
await OpenWakeWord.start(); await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed'); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`OpenWakeWord.start() OK → state=armed, keyword=${this.keyword}`)).catch(()=>{}); `OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
this.setState('armed'); this.setState('armed');
return; return;
+9 -1
View File
@@ -940,11 +940,19 @@ class Agent:
# Tools ausfuehren + Ergebnis als role=tool zurueck # Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls: for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"]) tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists
# liefert ~34 KB compact, das wurde hier auf 8 KB
# zugeschnitten und ARIA glaubte die Liste sei
# abgeschnitten obwohl der Skill alles korrekt
# paginiert hatte. Claude-Context vertraegt locker
# 50 KB pro Tool-Result.
messages.append(ProxyMessage( messages.append(ProxyMessage(
role="tool", role="tool",
tool_call_id=tc["id"], tool_call_id=tc["id"],
name=tc["name"], name=tc["name"],
content=tool_result[:8000], content=tool_result[:50000],
)) ))
continue # next iteration mit Tool-Results continue # next iteration mit Tool-Results
# Kein Tool-Call mehr → final reply # Kein Tool-Call mehr → final reply
+129
View File
@@ -602,6 +602,135 @@ SEED_RULES: List[dict] = [
"'API Key' im Auth-Kapitel). Nicht raten." "'API Key' im Auth-Kapitel). Nicht raten."
), ),
}, },
{
"migration_key": "seed/voice/tts-voice-tag",
"type": "rule",
"title": "TTS-sprechbar: `<voice>...</voice>`-Tag fuer Antworten mit Einheiten/Zahlen/Markdown",
"category": "voice",
"content": (
"Die App spielt jede ARIA-Antwort als TTS ab. Der Brain-Bridge "
"filtert Markdown raus (Sternchen, Code-Bloecke, URLs), kennt "
"aber keine Einheiten-/Zahlen-Konvention — der Sprecher liest "
"dann '15 kt' als 'fuenfzehn k t' und '23,5°C' als 'dreiund-"
"zwanzig komma fuenf grad c'. Klingt scheisse.\n"
"\n"
"LOESUNG: Wenn deine Antwort eine der folgenden Eigenschaften hat, "
"haenge einen `<voice>...</voice>`-Block ans ENDE der Antwort. "
"Was DRIN steht ersetzt komplett den TTS-Text — Markdown im "
"Chat-Display bleibt unangetastet, gesprochen wird ausschliess-"
"lich die <voice>-Variante.\n"
"\n"
"WANN <voice>-Tag setzen:\n"
" - Einheiten-Abkuerzungen: kt, kg, km/h, °C, hPa, mbar, mph, "
" psi, dB, GB, MB, kWh, mAh ...\n"
" - Zahlen mit Komma (23,5 → 'dreiundzwanzig komma fuenf')\n"
" - Uhrzeiten mit Minuten (8:42 → 'acht Uhr zweiundvierzig')\n"
" - Wettervorhersagen / Statusberichte mit mehreren Daten\n"
" - Tabellen oder Listen mit Werten\n"
" - Lange Zahlen / IDs / Codes ('spotify:playlist:abc' nicht "
" vorlesen)\n"
" - Code-Bloecke (sollte ARIA in Sprache eh nicht zitieren)\n"
"\n"
"WANN NICHT (Overhead vermeiden):\n"
" - Kurze Statussaetze ('OK', 'mach ich', 'klar', 'spielt')\n"
" - Reine Prosa ohne Zahlen oder Einheiten\n"
" - Antworten unter 15 Worten ohne komplexes Element\n"
"\n"
"FORMAT:\n"
" Erst die Chat-Display-Variante (mit Markdown OK), dann an einer "
" neuen Zeile der <voice>-Block:\n"
"\n"
" Antwort-Text mit **Markdown**, Zahlen, Einheiten\n"
" <voice>Antwort-Text fuer den Lautsprecher, ausgeschrieben</voice>\n"
"\n"
"BEISPIEL Wetter:\n"
" **Wetter Berlin:** 23,5°C, Wind 15 kt aus NW, Druck 1018 hPa.\n"
" <voice>Das Wetter in Berlin: dreiundzwanzig Grad fuenf, "
" Wind mit fuenfzehn Knoten aus Nordwest, Luftdruck "
" tausendachtzehn Hektopascal.</voice>\n"
"\n"
"BEISPIEL Uhrzeit:\n"
" Stefan, dein Termin ist um **8:42** — noch 25 Minuten.\n"
" <voice>Stefan, dein Termin ist um acht Uhr zweiundvierzig. "
" Du hast noch fuenfundzwanzig Minuten.</voice>\n"
"\n"
"BEISPIEL Akku/Speicher:\n"
" Server: 87% Last, 12,4 GB RAM frei, Uptime 142h.\n"
" <voice>Server bei siebenundachtzig Prozent Last, zwoelf "
" Komma vier Gigabyte RAM frei, Laufzeit hundertzweiundvierzig "
" Stunden.</voice>\n"
"\n"
"BEISPIEL Multi-Track (NICHT vorlesen was nicht sprechbar ist):\n"
" Spielt jetzt: **Firestarter** (3:47) auf duffy-desktop.\n"
" <voice>Spielt jetzt Firestarter, drei Minuten siebenund-"
" vierzig.</voice> ← Device weglassen, war im Chat zur Info, "
" fuer Stefan akustisch redundant\n"
"\n"
"Der Voice-Tag wird automatisch aus Chat-Bubble und Chat-Backup "
"gestrippt — Stefan sieht NUR die Markdown-Variante in der App. "
"Voice-Text geht ausschliesslich an F5-TTS. Beide Welten happy.\n"
"\n"
"Sicherheitsnetz: wenn Du den Tag mal vergisst, faellt clean_text_"
"for_tts auf die alte Regex-Cleanup-Pipeline zurueck (Markdown weg, "
"Uhrzeiten teilweise ausgeschrieben). Aber 'kt' wird dann literal "
"vorgelesen. Also: lieber Tag setzen wenn unsicher."
),
},
{
"migration_key": "seed/skill-rule/list-api-pagination-snapshot",
"type": "rule",
"title": "Listen-API: einmal vollstaendig laden, DANN entscheiden",
"category": "verhalten",
"content": (
"Wenn ein Tool-Resultat ein Pagination-Schema hat (limit/offset/"
"next oder total > limit): ALLE Seiten in EINEM Tool-Call holen, "
"in EINEM Snapshot durchsuchen, ERST DANN handeln.\n"
"\n"
"Antipattern (31.05.2026, Stefan reproduziert mit 'Playlist Prodigy "
"raussuchen'):\n"
" - run_spotify path=/v1/me/playlists?limit=50\n"
"'nicht dabei'\n"
" - run_spotify path=/v1/me/playlists?limit=50&offset=50\n"
"'gefunden, ID=X' (46 Tracks)\n"
" - run_spotify path=/v1/me/player/play body={context_uri: ...:X}\n"
" → spielt aber FALSCHE Playlist\n"
" - Neue Suche, wieder paginiert → drittes Match ID=Y (15 Tracks)\n"
" - Insgesamt drei verschiedene IDs fuer dieselbe gesuchte Playlist\n"
" generiert, am Ende die falsche gespielt.\n"
"\n"
"Wurzel: Spotify sortiert /v1/me/playlists nach recently-played. "
"Zwischen aufeinanderfolgenden paginierten Calls AENDERT SICH die "
"Reihenfolge wenn parallel was abgespielt wird. Teilresultate aus "
"verschiedenen Calls vergleichen → inkonsistent.\n"
"\n"
"Richtig fuer Spotify (seit 31.05.2026 unterstuetzt):\n"
" run_spotify path=/v1/me/playlists?limit=50&_all=true\n"
" → Skill paginiert intern, liefert {items, total, fetched_count}.\n"
" → In items[] suchen, EINE ID waehlen, sofort handeln.\n"
" → Match-Logik: bevorzugt exakter Name (case-insensitive). "
"Wenn mehrere Substring-Matches: explizit nachfragen statt raten.\n"
"\n"
"Wann _all=true sinnvoll:\n"
" - /v1/me/playlists (alle eigenen Playlists)\n"
" - /v1/playlists/{id}/tracks (alle Tracks einer Playlist)\n"
" - /v1/me/tracks (Liked Songs)\n"
" - /v1/search?type=playlist&q=... (Such-Ergebnisse mit next)\n"
" - Andere Endpunkte mit items+next-Schema.\n"
"\n"
"Wann NICHT _all=true:\n"
" - /v1/me/player/currently-playing (kein Listen-Endpunkt)\n"
" - /v1/me/player/devices (kurze Liste, kein next)\n"
" - Wenn Du explizit nur 'die ersten 10' willst.\n"
"\n"
"Fuer andere Skills (yt-dlp, andere APIs) die noch kein _all "
"unterstuetzen: manuell paginieren bis total erreicht, ALLES in "
"EINEM mentalen Snapshot mergen, NIEMALS auf Teilresultaten "
"Entscheidungen treffen. Wenn zwei Pagination-Runs unterschiedliche "
"Matches liefern: ehrlich melden ('zwei verschiedene Playlists "
"namens X gefunden — welche meinst Du?') statt sich auf eine "
"festzulegen."
),
},
] ]
+21 -5
View File
@@ -683,8 +683,13 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
timed_out = True timed_out = True
duration = time.time() - t0 duration = time.time() - t0
# Log schreiben (gekuerzt damit es nicht explodiert) # Log auf der Disk wird gekuerzt (8000 chars) — sonst sammeln sich
record = { # logs/*.json mit MBs an grossen Skill-Outputs an. Der Return-Value
# an den Caller (Agent) bekommt aber den vollen Output, dort wird
# nochmal in agent.py auf 50000 gecappt. Stefan-Fall: spotify-Skill
# mit _all=true liefert 50+ KB JSON, das hier wurde vorher auf 8 KB
# gekappt → ARIA sah immer nur den Anfang der Liste.
log_record = {
"ts": _now(), "ts": _now(),
"args": args or {}, "args": args or {},
"exit_code": exit_code, "exit_code": exit_code,
@@ -694,7 +699,7 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
"timed_out": timed_out, "timed_out": timed_out,
} }
try: try:
log_path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8") log_path.write_text(json.dumps(log_record, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception: except Exception:
pass pass
@@ -703,8 +708,19 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
manifest["use_count"] = int(manifest.get("use_count", 0)) + 1 manifest["use_count"] = int(manifest.get("use_count", 0)) + 1
write_manifest(name, manifest) write_manifest(name, manifest)
record["ok"] = exit_code == 0 # Return-Value: nicht kuerzen (Agent kuerzt downstream selbst). Nur
record["log_path"] = str(log_path) # die Disk-Log-Variante war beschnitten.
record = {
"ts": log_record["ts"],
"args": log_record["args"],
"exit_code": exit_code,
"duration_sec": log_record["duration_sec"],
"stdout": out_text or "",
"stderr": err_text or "",
"timed_out": timed_out,
"ok": exit_code == 0,
"log_path": str(log_path),
}
return record return record
+158 -4
View File
@@ -208,6 +208,30 @@ _UNIT_WORDS = [
] ]
def strip_voice_tag_for_display(text: str) -> str:
"""Entfernt `<voice>...</voice>`-Bloecke aus dem Chat-Display-Text.
ARIA kann einen <voice>-Block ANHAENGEN um eine TTS-freundliche Variante
ihrer Antwort zu liefern (Zahlen ausgeschrieben, Einheiten als Wort,
Markdown entfernt). Der Block wird dann von clean_text_for_tts als
TTS-Quelle benutzt — fuer die Chat-Bubble in der App soll er aber NICHT
sichtbar sein, sonst sieht Stefan literal '<voice>...' in seinem Chat.
Beispiel-Input (Stefan-typisch fuer Wetterbericht):
'**Wetter:** 23,5°C, Wind 15 kt NW\\n<voice>Wetter: dreiundzwanzig
komma fuenf Grad, Wind fuenfzehn Knoten Nordwest.</voice>'
Output:
'**Wetter:** 23,5°C, Wind 15 kt NW'
Mehrere Voice-Bloecke werden alle entfernt (ARIA koennte theoretisch
mehrere setzen, machen wir robust). Trailing-Whitespace nach dem Block
auch wegtrimmen.
"""
if not text or "<voice>" not in text.lower():
return text
return _re_tts.sub(r'\s*<voice>[\s\S]*?</voice>\s*', '\n', text, flags=_re_tts.IGNORECASE).strip()
def clean_text_for_tts(text: str) -> str: def clean_text_for_tts(text: str) -> str:
"""Bereitet Chat-Text fuer Sprachausgabe auf. """Bereitet Chat-Text fuer Sprachausgabe auf.
@@ -1150,11 +1174,15 @@ class ARIABridge:
f"aber nicht erstellt:\n{missing_list}\n" f"aber nicht erstellt:\n{missing_list}\n"
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip() "Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker) # Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker
# UND ohne <voice>-Tag — der ist eine TTS-Annotation, gehoert nicht in
# die Chat-Historie weil ARIA ihre eigene Vorgaenger-Antwort sonst mit
# Voice-Tag-Noise als Kontext sieht).
# File-Marker werden separat als file_from_aria-Events ausgeliefert. # File-Marker werden separat als file_from_aria-Events ausgeliefert.
display_text = strip_voice_tag_for_display(text)
assistant_backup_ts = self._append_chat_backup({ assistant_backup_ts = self._append_chat_backup({
"role": "assistant", "role": "assistant",
"text": text, "text": display_text,
"files": [{"serverPath": f["serverPath"], "name": f["name"], "files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files], "mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
}) })
@@ -1181,11 +1209,14 @@ class ARIABridge:
# TTS-aufbereitete Variante fuer Debug (Diagnostic zeigt optional) # TTS-aufbereitete Variante fuer Debug (Diagnostic zeigt optional)
tts_text_preview = clean_text_for_tts(text) tts_text_preview = clean_text_for_tts(text)
# Antwort an die App weiterleiten (als Chat-Nachricht) # Antwort an die App weiterleiten (als Chat-Nachricht).
# display_text == text aber ohne <voice>-Tag — der lebt nur transient
# in `text` damit clean_text_for_tts weiter unten daraus die TTS-
# Variante zieht. Im Chat-Bubble soll der Tag nicht erscheinen.
await self._send_to_rvs({ await self._send_to_rvs({
"type": "chat", "type": "chat",
"payload": { "payload": {
"text": text, "text": display_text,
"sender": "aria", "sender": "aria",
"messageId": message_id, "messageId": message_id,
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als # backupTs = der ts in chat_backup.jsonl. Wird von Clients als
@@ -2374,6 +2405,129 @@ class ARIABridge:
logger.warning("[rvs] file_delete_request: %s", e) logger.warning("[rvs] file_delete_request: %s", e)
return return
elif msg_type == "file_version_list_request":
# Versions-Historie einer Datei (App-Side Dateimanager).
# Pfad ist relativ-zu-/shared/uploads, kommt vom App-File-Manager
# der eh nur diesen flachen Bereich anzeigt. Diagnostic hat die
# git-Logik — wir proxien.
req_path = payload.get("path", "")
logger.info("[rvs] file_version_list_request: %s", req_path)
try:
qs = urllib.parse.urlencode({"path": req_path})
req = urllib.request.Request(
f"http://localhost:3001/api/files-versions?{qs}",
method="GET",
)
def _do_list():
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8", errors="ignore"))
except Exception as e:
return {"ok": False, "error": str(e)}
d = await asyncio.get_event_loop().run_in_executor(None, _do_list)
await self._send_to_rvs({
"type": "file_version_list_response",
"payload": d,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_list_request: %s", e)
return
elif msg_type == "file_version_download_request":
# Inhalt einer alten Version holen, base64 zurueck. Diagnostic
# liefert Binary, wir wrappen als base64 in der Response damit
# die App's RVS-WS damit umgehen kann.
req_path = payload.get("path", "")
req_hash = payload.get("hash", "")
req_id = payload.get("requestId", "")
logger.info("[rvs] file_version_download_request: %s @ %s",
req_path, req_hash[:7])
try:
qs = urllib.parse.urlencode({"path": req_path, "hash": req_hash})
req = urllib.request.Request(
f"http://localhost:3001/api/files-version-content?{qs}",
method="GET",
)
def _do_dl():
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except Exception as e:
return None, str(e).encode("utf-8")
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_dl)
if status == 200 and isinstance(body, (bytes, bytearray)):
await self._send_to_rvs({
"type": "file_version_download_response",
"payload": {
"ok": True,
"requestId": req_id,
"path": req_path,
"hash": req_hash,
"base64": base64.b64encode(body).decode("ascii"),
"size": len(body),
"name": (req_path.rsplit("/", 1)[-1] or "file"),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
else:
err = body.decode("utf-8", "ignore") if isinstance(body, (bytes, bytearray)) else str(body)
await self._send_to_rvs({
"type": "file_version_download_response",
"payload": {
"ok": False,
"requestId": req_id,
"path": req_path,
"hash": req_hash,
"error": f"HTTP {status}: {err[:200]}",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_download_request: %s", e)
return
elif msg_type == "file_version_restore_request":
# Eine Version als neue aktive setzen — non-destructive
# (diagnostic schreibt den alten Inhalt + macht einen neuen Commit).
req_path = payload.get("path", "")
req_hash = payload.get("hash", "")
logger.warning("[rvs] file_version_restore_request: %s <- %s",
req_path, req_hash[:7])
try:
body_bytes = json.dumps({"path": req_path, "hash": req_hash}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:3001/api/files-version-restore",
data=body_bytes,
method="POST",
headers={"Content-Type": "application/json"},
)
def _do_restore():
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_restore)
try:
parsed = json.loads(body) if body else {"ok": False, "error": "leer"}
except Exception:
parsed = {"ok": False, "error": body[:200]}
if status != 200 and "ok" not in parsed:
parsed = {"ok": False, "error": f"HTTP {status}"}
await self._send_to_rvs({
"type": "file_version_restore_response",
"payload": parsed,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_version_restore_request: %s", e)
return
elif msg_type == "location_update": elif msg_type == "location_update":
# Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in # Live-GPS-Update von der App (nicht an Chat gekoppelt). Wird in
# /shared/state/location.json geschrieben, damit Watcher-Trigger # /shared/state/location.json geschrieben, damit Watcher-Trigger
+2 -1
View File
@@ -1,7 +1,8 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip) # zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
RUN apk add --no-cache zip # git fuer Auto-Versionierung von /shared/uploads/ (siehe server.js)
RUN apk add --no-cache zip git
COPY package.json ./ COPY package.json ./
RUN npm install --production RUN npm install --production
COPY . . COPY . .
+90
View File
@@ -4038,12 +4038,85 @@
<div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div> <div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div>
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div> <div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
</div> </div>
<button class="btn secondary" onclick="openFileInline('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Öffnen">👁</button>
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen"></button> <button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen"></button>
<button class="btn secondary" onclick="showVersions('${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;" title="Versionen">🕒</button>
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button> <button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
</div>`; </div>`;
}).join(''); }).join('');
} }
// ── Versions-Modal ──────────────────────────────────────
async function showVersions(fileName) {
// path-relative-to-/shared/uploads ist hier == fileName, weil unser
// file-Manager-Verzeichnis flach ist
const rel = fileName;
const modal = document.getElementById('versions-modal');
const title = document.getElementById('versions-title');
const body = document.getElementById('versions-body');
title.textContent = `Versionen — ${fileName}`;
body.innerHTML = '<div style="color:#8888AA;text-align:center;padding:20px;">Lade...</div>';
modal.style.display = 'flex';
modal.dataset.path = rel;
try {
const r = await fetch('/api/files-versions?path=' + encodeURIComponent(rel));
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
if (!d.versions.length) {
body.innerHTML = '<div style="color:#8888AA;text-align:center;padding:20px;">Noch keine Versions-Historie (Datei kommt erst nach naechstem Auto-Commit in den Index).</div>';
return;
}
const fmtDate = (ms) => new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
body.innerHTML = d.versions.map(v => {
const isCur = v.isCurrent
? '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">AKTIV</span>'
: '';
const subjShort = (v.subject || '').slice(0, 60);
return `<div style="padding:10px;border-bottom:1px solid #1E1E2E;display:flex;gap:8px;align-items:center;">
<div style="flex:1;min-width:0;">
<div style="color:#E0E0F0;font-size:12px;">${isCur}<code style="color:#0096FF;">${v.hash.slice(0,7)}</code> · ${escapeHtml(subjShort)}</div>
<div style="color:#555570;font-size:10px;">${fmtDate(v.ts)}</div>
</div>
<button class="btn secondary" onclick="downloadVersion('${escapeHtml(rel)}','${v.hash}')" style="padding:3px 10px;font-size:11px;">⬇ Download</button>
${v.isCurrent ? '' : `<button class="btn" onclick="restoreVersion('${escapeHtml(rel)}','${v.hash}')" style="padding:3px 10px;font-size:11px;background:#0096FF;color:#fff;">⟲ Restore</button>`}
</div>`;
}).join('');
} catch (e) {
body.innerHTML = `<div style="color:#FF6B6B;padding:20px;">${escapeHtml(e.message)}</div>`;
}
}
function closeVersionsModal() {
document.getElementById('versions-modal').style.display = 'none';
}
function downloadVersion(rel, hash) {
const url = '/api/files-version-content?path=' + encodeURIComponent(rel) + '&hash=' + encodeURIComponent(hash);
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a); a.click();
setTimeout(() => a.remove(), 100);
}
async function restoreVersion(rel, hash) {
if (!confirm(`Diese Version (${hash.slice(0,7)}) als aktive Version setzen?\n\nDie aktuelle Version bleibt rollback-bar in der Historie.`)) return;
try {
const r = await fetch('/api/files-version-restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: rel, hash }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
// Modal neu laden mit aktualisierter Liste
showVersions(rel);
loadFiles();
} catch (e) {
alert('Restore fehlgeschlagen: ' + e.message);
}
}
async function downloadSelected() { async function downloadSelected() {
const paths = [...filesSelected]; const paths = [...filesSelected];
if (!paths.length) return; if (!paths.length) return;
@@ -4102,6 +4175,12 @@
window.location.href = '/api/files-download?path=' + encPath; window.location.href = '/api/files-download?path=' + encPath;
} }
function openFileInline(encPath) {
// Inline-View — Browser zeigt PDF / Bild / Text im neuen Tab,
// bei unbekanntem MIME landet's als Download-Fallback.
window.open('/api/files-view?path=' + encPath, '_blank', 'noopener');
}
async function deleteFile(p, name) { async function deleteFile(p, name) {
if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return; if (!confirm(`Datei "${name}" wirklich löschen?\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`)) return;
try { try {
@@ -5612,5 +5691,16 @@
// History gleich nach Seitenstart laden damit Browser-Reload nichts verliert. // History gleich nach Seitenstart laden damit Browser-Reload nichts verliert.
loadAriaStreamHistory(); loadAriaStreamHistory();
</script> </script>
<!-- Versions-Modal fuer Datei-Manager -->
<div id="versions-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:1000;align-items:center;justify-content:center;" onclick="if(event.target===this)closeVersionsModal()">
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:8px;width:90%;max-width:600px;max-height:80vh;display:flex;flex-direction:column;">
<div style="padding:12px 16px;border-bottom:1px solid #1E1E2E;display:flex;align-items:center;gap:8px;">
<strong id="versions-title" style="color:#E0E0F0;flex:1;font-size:13px;">Versionen</strong>
<button class="btn secondary" onclick="closeVersionsModal()" style="padding:4px 10px;font-size:11px;">✕ Schliessen</button>
</div>
<div id="versions-body" style="overflow-y:auto;padding:4px 12px;"></div>
</div>
</div>
</body> </body>
</html> </html>
+276 -3
View File
@@ -92,6 +92,174 @@ let activeSessionKey = (() => {
return "main"; return "main";
})(); })();
// ── Auto-Versionierung /shared/uploads/ via git ────────────────
//
// Jede Aenderung im uploads/-Verzeichnis (User-Upload, ARIA-Generate,
// ARIA-Bearbeitung) wird durch eine 30s-Polling-Loop in einen git-Commit
// gepackt. Idempotent (kein Commit ohne Diff), kein Bloat im Normalbetrieb.
// Stefan kann via UI eine Version anschauen, herunterladen oder als
// neue aktive Version setzen (Restore = neuer commit mit altem Inhalt,
// non-destructive).
const SHARED_UPLOADS = "/shared/uploads";
const VERSIONING_INTERVAL_MS = 30 * 1000;
const { execFile } = require("child_process");
function git(args, opts = {}) {
return new Promise((resolve, reject) => {
const child = execFile(
"git",
["-C", SHARED_UPLOADS, ...args],
{ maxBuffer: 20 * 1024 * 1024, ...opts },
(err, stdout, stderr) => {
if (err && !opts.allowFail) {
err.stderr = stderr;
return reject(err);
}
resolve({
stdout: stdout || "",
stderr: stderr || "",
code: err ? (err.code || 1) : 0,
});
},
);
if (opts.input != null) {
try { child.stdin.write(opts.input); } catch (_) {}
try { child.stdin.end(); } catch (_) {}
}
});
}
async function initSharedVersioning() {
try {
fs.mkdirSync(SHARED_UPLOADS, { recursive: true });
} catch (e) {
console.error(`[shared-git] mkdir uploads fehlgeschlagen: ${e.message}`);
return;
}
const gitDir = path.join(SHARED_UPLOADS, ".git");
if (!fs.existsSync(gitDir)) {
console.log("[shared-git] Initialisiere /shared/uploads als git-Repo");
try {
await git(["init", "-q", "-b", "main"]);
await git(["config", "user.email", "aria@diagnostic"]);
await git(["config", "user.name", "aria-diagnostic"]);
// Initial commit (auch wenn leer) damit log/checkout immer funktioniert
await git(["commit", "-q", "--allow-empty", "-m", "initial snapshot"]);
// Falls schon Files drin sind: noch ein 'auto'-Commit hinten dran
const status = await git(["status", "--porcelain"]);
if (status.stdout.trim()) {
await git(["add", "-A"]);
await git(["commit", "-q", "-m", `auto: ${new Date().toISOString()}`]);
}
console.log("[shared-git] Init OK");
} catch (e) {
console.error(`[shared-git] Init fehlgeschlagen: ${e.message}`);
return;
}
} else {
console.log("[shared-git] Bestehendes git-Repo erkannt — uebernehme");
}
setInterval(autoCommitTick, VERSIONING_INTERVAL_MS);
console.log(`[shared-git] Auto-Commit-Loop alle ${VERSIONING_INTERVAL_MS}ms aktiv`);
}
let autoCommitBusy = false;
async function autoCommitTick() {
if (autoCommitBusy) return; // re-entrancy guard fuer langsame git ops
autoCommitBusy = true;
try {
const status = await git(["status", "--porcelain"]);
if (!status.stdout.trim()) return;
await git(["add", "-A"]);
const ts = new Date().toISOString();
await git(["commit", "-q", "-m", `auto: ${ts}`]);
console.log(`[shared-git] auto-commit @ ${ts}`);
} catch (e) {
console.error(`[shared-git] auto-commit fehlgeschlagen: ${e.message}`);
} finally {
autoCommitBusy = false;
}
}
// Versions-API helpers — werden weiter unten von den Routen genutzt.
function isPathSafe(rel) {
if (!rel || typeof rel !== "string") return false;
if (rel.includes("..") || rel.startsWith("/") || rel.startsWith(".git")) return false;
return true;
}
async function listVersionsForFile(rel) {
// git log --follow damit Renames trotzdem die Historie zeigen.
// NUL-Separator damit Subjects mit Leerzeichen nicht falsch splitten.
const out = await git(["log", "--follow", "--format=%H%x00%aI%x00%s", "--", rel]);
const lines = out.stdout.trim().split("\n").filter(Boolean);
const enriched = [];
for (const line of lines) {
const [hash, isoTs, subject] = line.split("\x00");
if (!hash) continue;
let blob = null;
try {
const ls = await git(["ls-tree", hash, "--", rel]);
// Format: "100644 blob <40-hex>\t<path>"
const m = ls.stdout.match(/blob ([0-9a-f]{40})/);
if (m) blob = m[1];
} catch (_) {
continue;
}
if (!blob) continue;
enriched.push({ hash, ts: Date.parse(isoTs) || 0, subject: subject || "", blob });
}
// Dedup auf Blob-Ebene — Restore-Commits sind inhaltlich gleich mit dem
// restorten alten Commit. Zeige nur den AELTESTEN (= zuerst erschienenen)
// Eintrag pro identischem Blob. Damit blaeht Restore die Liste nicht auf.
const seen = new Set();
const unique = [];
for (let i = enriched.length - 1; i >= 0; i--) {
const v = enriched[i];
if (seen.has(v.blob)) continue;
seen.add(v.blob);
unique.push(v);
}
unique.reverse(); // wieder neueste-zuerst fuers UI
// AKTIV-Marker: Commit dessen Blob == aktuelle Working-Copy. Nach Restore
// wandert AKTIV auf den restorten alten Stand, nicht auf den gefilterten
// Restore-Commit.
let currentBlob = null;
try {
const abs = path.join(SHARED_UPLOADS, rel);
if (fs.existsSync(abs)) {
const r = await git(["hash-object", abs]);
currentBlob = (r.stdout || "").trim();
}
} catch (_) {}
for (const v of unique) {
if (currentBlob && v.blob === currentBlob) v.isCurrent = true;
}
// Blob aus Response strippen — sieht im UI aus wie zweite Commit-ID, unnoetig.
return unique.map(({ blob, ...rest }) => rest);
}
async function getVersionContent(rel, hash) {
// git show <hash>:<path> liefert den Inhalt aus diesem Commit
// Binary-safe via stdio buffer
const out = await git(["show", `${hash}:${rel}`], { encoding: "buffer" });
return out.stdout; // Buffer
}
async function restoreVersion(rel, hash) {
// Variante: non-destructive — wir holen den alten Inhalt und schreiben
// ihn als NEUE Version drueber. Damit bleibt die aktuelle Version
// ebenfalls in der git-History rollback-bar.
const content = await getVersionContent(rel, hash);
const abs = path.join(SHARED_UPLOADS, rel);
fs.writeFileSync(abs, content);
await git(["add", "--", rel]);
await git(["commit", "-q", "-m", `restore: ${rel} <- ${hash.slice(0, 7)}`]);
return true;
}
// Beim Startup einmalig aufrufen
initSharedVersioning().catch(e =>
console.error(`[shared-git] initSharedVersioning crashed: ${e.message}`),
);
// ── Runtime-Config: /shared/config/runtime.json ───────────── // ── Runtime-Config: /shared/config/runtime.json ─────────────
// ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang. // ENV-Werte sind Defaults; Werte aus runtime.json haben Vorrang.
// Bridge und ggf. andere Komponenten lesen dieselbe Datei. // Bridge und ggf. andere Komponenten lesen dieselbe Datei.
@@ -1454,7 +1622,10 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ ok: false, error: err.message })); res.end(JSON.stringify({ ok: false, error: err.message }));
} }
return; return;
} else if (req.url.startsWith("/api/files-download?") && req.method === "GET") { } else if ((req.url.startsWith("/api/files-download?") || req.url.startsWith("/api/files-view?")) && req.method === "GET") {
// /api/files-download → mit Content-Disposition:attachment (Browser downloaded)
// /api/files-view → mit Disposition:inline (Browser zeigt PDF/Bilder im Tab)
const isInline = req.url.startsWith("/api/files-view?");
const u = new URL("http://x" + req.url); const u = new URL("http://x" + req.url);
const p = u.searchParams.get("path") || ""; const p = u.searchParams.get("path") || "";
const safe = path.resolve(p); const safe = path.resolve(p);
@@ -1465,10 +1636,26 @@ const server = http.createServer((req, res) => {
} }
const stat = fs.statSync(safe); const stat = fs.statSync(safe);
const fname = path.basename(safe); const fname = path.basename(safe);
// Beim View-Modus echten MIME-Type setzen damit Browser inline rendert.
// Bei Download-Modus weiter octet-stream + attachment-Disposition.
const ext = path.extname(fname).toLowerCase();
const mimeMap = {
".pdf": "application/pdf",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
".mp4": "video/mp4", ".webm": "video/webm",
".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8",
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
".json": "application/json; charset=utf-8", ".csv": "text/csv; charset=utf-8",
".zip": "application/zip",
};
const mime = isInline ? (mimeMap[ext] || "application/octet-stream")
: "application/octet-stream";
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "application/octet-stream", "Content-Type": mime,
"Content-Length": stat.size, "Content-Length": stat.size,
"Content-Disposition": `attachment; filename="${fname}"`, "Content-Disposition": `${isInline ? "inline" : "attachment"}; filename="${fname}"`,
}); });
fs.createReadStream(safe).pipe(res); fs.createReadStream(safe).pipe(res);
return; return;
@@ -1594,6 +1781,92 @@ const server = http.createServer((req, res) => {
} }
}); });
return; return;
} else if (req.url.startsWith("/api/files-versions?") && req.method === "GET") {
// Liste der git-Versionen einer Datei. Query: ?path=<rel-to-uploads>
const u = new URL("http://x" + req.url);
const rel = u.searchParams.get("path") || "";
if (!isPathSafe(rel)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad" }));
return;
}
listVersionsForFile(rel)
.then(versions => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, path: rel, versions }));
})
.catch(err => {
log("warn", "server", `files-versions failed: ${err.message}`);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
return;
} else if (req.url.startsWith("/api/files-version-content?") && req.method === "GET") {
// Inhalt einer alten Version downloaden. Query: ?path=...&hash=<sha>
const u = new URL("http://x" + req.url);
const rel = u.searchParams.get("path") || "";
const hash = u.searchParams.get("hash") || "";
if (!isPathSafe(rel) || !/^[0-9a-f]{7,40}$/i.test(hash)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
return;
}
getVersionContent(rel, hash)
.then(content => {
const base = path.basename(rel);
const stem = base.replace(/(\.[^.]+)?$/, "");
const ext = path.extname(base);
const shortHash = hash.slice(0, 7);
const downloadName = `${stem}@${shortHash}${ext}`;
res.writeHead(200, {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${downloadName}"`,
"Content-Length": content.length,
});
res.end(content);
})
.catch(err => {
log("warn", "server", `files-version-content failed: ${err.message}`);
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
return;
} else if (req.url === "/api/files-version-restore" && req.method === "POST") {
// Eine alte Version als neue aktive Version setzen — non-destructive,
// erzeugt einen neuen "restore:"-Commit. Body: {path, hash}
let body = "";
req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
req.on("end", () => {
let p, h;
try {
const parsed = JSON.parse(body || "{}");
p = String(parsed.path || "");
h = String(parsed.hash || "");
} catch (e) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "bad json" }));
return;
}
if (!isPathSafe(p) || !/^[0-9a-f]{7,40}$/i.test(h)) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "ungueltiger Pfad oder Hash" }));
return;
}
restoreVersion(p, h)
.then(() => {
log("info", "server", `Version restored: ${p} <- ${h.slice(0,7)}`);
// Datei hat sich geaendert — Browser-Listen invalidieren
broadcast({ type: "file_version_restored", path: p, hash: h });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, path: p, hash: h }));
})
.catch(err => {
log("warn", "server", `restore failed: ${err.message}`);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
});
});
return;
} else if (req.url === "/api/voice-config-export" && req.method === "GET") { } else if (req.url === "/api/voice-config-export" && req.method === "GET") {
// voice_config.json + highlight_triggers.json als JSON-Bundle exportieren // voice_config.json + highlight_triggers.json als JSON-Bundle exportieren
try { try {
+2
View File
@@ -13,6 +13,8 @@ services:
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js && sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js && sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js && cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js && cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js && cp /proxy-patches/routes.js $$DIST/server/routes.js &&
+5
View File
@@ -42,6 +42,11 @@ const ALLOWED_TYPES = new Set([
// die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip. // die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip.
"stt_stream_start", "stt_audio_chunk", "stt_stream_end", "stt_stream_start", "stt_audio_chunk", "stt_stream_end",
"stt_partial", "stt_endpoint", "stt_stream_done", "stt_partial", "stt_endpoint", "stt_stream_done",
// File-Versioning (Datei-Manager in App): Versionen pro Datei listen,
// alte Versionen herunterladen, Restore = non-destructive neuer Commit.
"file_version_list_request", "file_version_list_response",
"file_version_download_request", "file_version_download_response",
"file_version_restore_request", "file_version_restore_response",
"service_status", "service_status",
"config_request", "config_request",
"flux_request", "flux_response", "flux_request", "flux_response",
+76 -1
View File
@@ -109,7 +109,27 @@ class WhisperRunner:
segments, info = self.model.transcribe( segments, info = self.model.transcribe(
audio, language=language, beam_size=beam_size, vad_filter=vad_filter, audio, language=language, beam_size=beam_size, vad_filter=vad_filter,
) )
text = " ".join(seg.text.strip() for seg in segments) # Per-segment no_speech_prob auswerten: faster-whisper liefert das
# mit. Bei Stille/Rauschen halluziniert Whisper bekannte YouTube-
# Untertitel-Patterns ("Untertitelung des ZDF", "Vielen Dank fuer's
# Zuschauen", ...). Segmente mit hohem no_speech_prob filtern wir
# raus. Plus: bekannte Hallucination-Patterns explizit blacklisten.
kept = []
for seg in segments:
# no_speech_prob: 1.0 = sicher Stille; 0.0 = sicher Sprache.
# Threshold 0.6 ist nicht zu strikt (echte leise Sprache geht
# noch durch) und nicht zu locker (Halluzinationen werden
# zuverlaessig erwischt).
nsp = getattr(seg, "no_speech_prob", 0.0)
if nsp is not None and nsp >= 0.6:
continue
stext = (seg.text or "").strip()
if not stext:
continue
if _is_known_hallucination(stext):
continue
kept.append(stext)
text = " ".join(kept)
return text, info.duration return text, info.duration
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -117,6 +137,61 @@ class WhisperRunner:
return await loop.run_in_executor(None, _run) return await loop.run_in_executor(None, _run)
# Bekannte Whisper-Halluzinations-Patterns. Tritt typisch bei Stille oder
# Rauschen auf — Whispers Trainings-Corpus enthaelt Stunden von YouTube-
# Videos mit diesen Untertitel-Outros. Substring-Match (case-insensitive)
# ueber gestrippten Text. Wenn ein Segment EXAKT (nach Normalisierung) so
# aussieht, ist's mit ~99% Sicherheit eine Halluzination.
_HALLUCINATION_PHRASES = (
"untertitelung des zdf",
"untertitel im auftrag des zdf",
"untertitelung im auftrag des zdf",
"untertitel der amara.org community",
"untertitel von stephanie geiges",
"amara.org",
"untertitel: kerstin grass",
"vielen dank fuers zuschauen",
"vielen dank fürs zuschauen",
"vielen dank für's zuschauen",
"vielen dank fuer's zuschauen",
"vielen dank für das zuschauen",
"vielen dank fuer das zuschauen",
"danke für's zuschauen",
"danke fürs zuschauen",
"danke fuers zuschauen",
"subs by",
"subtitle by",
"subtitles by",
"thanks for watching",
)
def _normalize_for_hallu(text: str) -> str:
"""Lowercase + trailing-Satzzeichen/Whitespace strippen. Jahreszahlen
(4 Ziffern am Ende) auch entfernen 'Untertitelung des ZDF, 2020'
matcht damit auf 'untertitelung des zdf'."""
t = text.lower().strip()
# Entferne trailing punctuation incl. comma+digits
while t and t[-1] in ".,!? \t\n":
t = t[:-1]
# 4-stellige Jahreszahl am Ende
import re
t = re.sub(r"[,\s]+\d{4}$", "", t).strip()
while t and t[-1] in ".,!? \t\n":
t = t[:-1]
return t
def _is_known_hallucination(text: str) -> bool:
norm = _normalize_for_hallu(text)
if not norm:
return True
for pat in _HALLUCINATION_PHRASES:
if pat in norm:
return True
return False
def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray: def ffmpeg_to_float32(audio_b64: str, mime_type: str) -> np.ndarray:
"""Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM.""" """Dekodiert beliebiges Audio-Format → 16kHz mono float32 PCM."""
if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type: if "mp4" in mime_type or "m4a" in mime_type or "aac" in mime_type: