Compare commits

..

9 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
11 changed files with 859 additions and 12 deletions
+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 10809 versionCode 10200
versionName "0.1.8.9" 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.9", "version": "0.1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+270 -4
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.
@@ -506,9 +542,11 @@ const SettingsScreen: React.FC = () => {
if (message.type === ('file_response' as any)) { if (message.type === ('file_response' as any)) {
const p: any = message.payload || {}; const p: any = message.payload || {};
const reqId = (p.requestId as string) || ''; const reqId = (p.requestId as string) || '';
if (!reqId.startsWith('single-')) return; // nicht unsere Anfrage const isDownload = reqId.startsWith('single-');
const isOpen = reqId.startsWith('open-');
if (!isDownload && !isOpen) return; // andere Caller (ChatScreen etc.)
if (p.error) { if (p.error) {
ToastAndroid.show('Download fehlgeschlagen: ' + p.error, ToastAndroid.LONG); ToastAndroid.show((isOpen ? 'Öffnen' : 'Download') + ' fehlgeschlagen: ' + p.error, ToastAndroid.LONG);
return; return;
} }
const b64 = (p.base64 as string) || ''; const b64 = (p.base64 as string) || '';
@@ -518,10 +556,28 @@ const SettingsScreen: React.FC = () => {
'aria-download'; 'aria-download';
(async () => { (async () => {
try { 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 dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/${fileName}`; const filePath = `${dir}/${fileName}`;
// Falls Datei schon existiert: Suffix anhaengen damit nichts
// ueberschrieben wird.
let target = filePath; let target = filePath;
let i = 1; let i = 1;
while (await RNFS.exists(target)) { while (await RNFS.exists(target)) {
@@ -540,6 +596,74 @@ const SettingsScreen: React.FC = () => {
})(); })();
} }
// 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;
@@ -964,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(
@@ -991,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}
+12
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);
@@ -1530,6 +1540,8 @@ class AudioService {
// Pending Release-Timer canceln damit der nicht mitten in der TTS feuert. // Pending Release-Timer canceln damit der nicht mitten in der TTS feuert.
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestDuck().catch(() => {}); 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); }
}); });
+123
View File
@@ -2405,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: