Compare commits

...
8 Commits
Author SHA1 Message Date
duffyduck 5b5591aa72 release: bump version to 0.2.5.4 2026-10-02 01:51:14 +02:00
duffyduckandClaude Opus 5 b70623bb78 fix(app): Absturzschleife — Auto-Re-Download holte ungefragt ein 150-MB-Video
Die Absturzschleife (alle ~70s, stundenlang) war selbsttragend:

  App startet → Auto-Re-Download fordert 2s spaeter ALLE fehlenden Anhaenge an,
  ohne Groessen-Grenze → Bridge schickt das 150-MB-Video als ~200 MB base64 in
  EINER WebSocket-Nachricht → die App muss das puffern und durch JSON.parse
  schicken → nativer OOM (kein JS-Fehler, darum nie ein Stacktrace) → Android
  startet den Vordergrund-Dienst neu → App startet → von vorn.

Dieselbe Nachricht hat parallel whisper/f5tts/voxtral mit Code 1009 umgebracht
— darum lag der Absturz immer direkt hinter dem Audio-Abbau, und darum kam
jede gerade laufende Sprachaufnahme nie an.

Drei Bremsen:
- Auto-Re-Download ueberspringt alles ueber 25 MB. Bleibt als "150 MB —
  tippen zum Laden" stehen; der User entscheidet.
- Ausdrueckliches Antippen bremst bei 64 MB mit einer ehrlichen Meldung samt
  Server-Pfad, statt die App zu killen.
- Der Brotkrumen wird von der Crash-Meldung nicht mehr ueberschrieben. Bisher
  merkte sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung" —
  in einer Schleife war nach dem ersten Durchlauf jede Spur weg.

Diagnose kam aus /shared/logs/app.log (app_log via RVS), kein ADB noetig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 01:48:27 +02:00
duffyduckandClaude Opus 5 73b6827f6b fix(bridge): Log-Fluten durch wiederholte hello-Meldungen abstellen
sat_hello und worker_hello kommen im Takt von ~25-30s erneut und wurden jedes
Mal mit "online" geloggt. Dadurch war das Bridge-Log praktisch unlesbar: die
interessanten Zeilen (Fehler, STT, Turns) gingen zwischen hunderten
identischen Meldungen unter.

Jetzt wird nur geloggt, wenn eine Box/ein Satellit NEU ist oder sich etwas
Echtes geaendert hat (Modell, GPUs, Standort, caps, control) — so wie es
host_hello schon vorher gemacht hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 01:43:52 +02:00
duffyduckandClaude Opus 5 a7998bcd8b fix(rvs): Datei-Antworten gezielt an den Fragenden statt an den ganzen Raum
Der RVS ist ein Broadcast-Relay — bisher bekam jeder Client im Raum jede
Nachricht. Bei einem Datei-Re-Download war das fatal: ein 150-MB-Video wird
als base64 ~200 MB gross und ging an ALLE Clients, auch an die STT/TTS-Worker.
Die sind daran mit Code 1009 (message too big) gestorben und haben die gerade
laufende Sprachaufnahme mitgerissen — die Ursache von "erste Sprachnachricht
kommt nicht durch".

Wer eine Anfrage mit requestId stellt, wird jetzt gemerkt; die Antwort geht
nur an ihn zurueck (file_response, file_zip_response,
file_version_download_response, brain_response). Unbekannte requestId wird
wie bisher gebroadcastet, ein weggefallener Fragender verworfen statt an alle
geschickt.

Verifiziert mit drei WS-Clients gegen einen lokalen RVS: der Fragende bekommt
seine Antwort, der Unbeteiligte nicht, chat bleibt Broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 01:43:04 +02:00
duffyduck 07df538763 release: bump version to 0.2.5.3 2026-10-02 01:37:49 +02:00
duffyduckandClaude Opus 4.8 5761c5f6b9 fix(xtts): Worker-Neustartschleife — max_size an den RVS angleichen (50MB -> 1500MB)
Ursache der staendigen whisper/f5tts-Neustarts, direkt aus dem Box-Log:
  sent 1009 (message too big) frame with 209221780 bytes
  exceeds limit of 52428800 bytes

Der RVS ist ein BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer,
Voice-Upload, APK-Update) geht an JEDEN Client im Raum. Die aria-bridge nutzt
deshalb max_size=1500MB — die Worker standen auf 50MB (llm-adapter sogar 16MB).
Ein 209-MB-Frame liess sie die Verbindung mit Code 1009 schliessen; danach
TLS-Fallback auf ws:// (scheitert an Caddy), Reconnect, naechster Frame, von
vorn. Ergebnis: Neustart im Minutentakt, und jede laufende Aufnahme war weg —
das ist die eigentliche Ursache fuer 'erste Sprachnachricht kommt nicht durch'.

Alle vier Worker nutzen jetzt RVS_MAX_SIZE_MB (Default 1500), passend zu
RVS/Bridge. Die GPU war NICHT das Problem (4,3 von 8 GB belegt, zweite Karte leer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:33:22 +02:00
duffyduckandClaude Opus 4.8 87755b9c58 fix(app): Aufnahme-Sperre fragt den Socket statt den gemerkten Zustand
Beweis aus app.log 02.10 01:22: beim Aufnehmen fehlte 'bg.start slot=rec' —
die Aufnahme wurde abgelehnt, BEVOR sie begann. Ursache ist die in 10e7faa
eingefuehrte Sperre 'ohne Verbindung nicht aufnehmen': sie prueft den gemerkten
Zustand, und der steht wegen des Zombie-Bugs faelschlich auf 'disconnected',
obwohl die Verbindung laeuft (TTS kam ja an). Ergebnis: Stefan konnte gar nicht
mehr sprechen — die Sperre hat das Problem verschlimmert statt es zu verhindern.

rvs.isSocketOpen() fragt jetzt ws.readyState direkt; audio.ts nutzt das. Damit
ist die Sperre immun gegen falsche Zustandsmeldungen und blockt nur, wenn der
Socket wirklich zu ist. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:24:57 +02:00
duffyduckandClaude Opus 4.8 b6195d3a53 feat(app): Teilen-Ziel — Bilder/Videos/Dateien aus anderen Apps an ARIA
'Teilen -> ARIA' aus WhatsApp, Galerie, Browser & Co. Ablauf wie gewuenscht:
sehen WAS geteilt wurde -> WOHIN (Projekt oder Hauptchat) -> optional Text dazu.
Ohne Text geht genauso.

- Manifest: intent-filter fuer ACTION_SEND (text/image/video/audio/application)
  und ACTION_SEND_MULTIPLE. launchMode=singleTask war bereits gesetzt.
- ShareIntentModule (Kotlin): liest den Intent und kopiert die Inhalte SOFORT in
  den App-Cache — die Leseerlaubnis fuer content://-URIs gilt nur fuer diesen
  Intent und waere nach der Projektauswahl womoeglich erloschen. Liefert echte
  Dateipfade, dadurch laeuft Teilen durch exakt dieselbe Upload-Strecke wie die
  Bueroklammer (RNFS -> Base64 -> Anhang im Chat).
- MainActivity.onNewIntent: setIntent + Event an JS (best effort). Zweitweg in
  JS ueber AppState 'active' -> getSharedData(), falls das Event je nach
  RN-Architektur nicht durchkommt; getSharedData markiert den Intent als
  verbraucht, also kein doppeltes Aufpoppen.
- ShareModal: Vorschau der geteilten Inhalte, Projektliste (inkl. Hauptchat),
  optionales Textfeld. Senden erst nach Zielwahl moeglich.
- sendPendingAttachments nimmt die Anhaenge jetzt optional als Parameter — beim
  Teilen waere der State zum Aufrufzeitpunkt noch leer.
tsc --noEmit: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:19:55 +02:00
19 changed files with 809 additions and 35 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20502
versionName "0.2.5.2"
versionCode 20504
versionName "0.2.5.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -49,6 +49,28 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
<!-- Mehrere Dateien auf einmal. -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
<provider
@@ -1,5 +1,6 @@
package com.ariacockpit
import android.content.Intent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
/**
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
* erst beim naechsten Kaltstart auftauchen.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
try {
val ctx = (application as? com.facebook.react.ReactApplication)
?.reactNativeHost?.reactInstanceManager?.currentReactContext
ShareIntentModule.notifyNewIntent(
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
} catch (_: Throwable) {
}
}
}
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,154 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.io.File
/**
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
* Browser ...) per "Teilen → ARIA" geschickt werden.
*
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
*
* Ablauf:
* App geschlossen → Intent liegt beim Start an → getSharedData()
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
*/
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ShareIntent"
companion object {
private const val EVENT = "ShareReceived"
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
@JvmStatic
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
if (ctx == null || intent == null || !isShare(intent)) return
try {
val data = extract(ctx, intent)
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(EVENT, data)
} catch (_: Throwable) {
}
}
private fun isShare(i: Intent): Boolean =
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
val out = Arguments.createMap()
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
val uris = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) {
@Suppress("DEPRECATION")
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?.forEach { uris.add(it) }
}
val items: WritableArray = Arguments.createArray()
for (uri in uris) {
copyToCache(ctx, uri)?.let { items.pushMap(it) }
}
out.putArray("items", items)
return out
}
/**
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
* Projektauswahl) waere sie womoeglich schon erloschen.
*/
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
return try {
val cr = ctx.contentResolver
var name = "geteilt"
var size = 0L
cr.query(uri, null, null, null, null)?.use { c ->
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val si = c.getColumnIndex(OpenableColumns.SIZE)
if (c.moveToFirst()) {
if (ni >= 0) c.getString(ni)?.let { name = it }
if (si >= 0) size = c.getLong(si)
}
}
val type = cr.getType(uri) ?: "application/octet-stream"
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
cr.openInputStream(uri)?.use { input ->
dst.outputStream().use { output -> input.copyTo(output) }
} ?: return null
Arguments.createMap().apply {
putString("path", dst.absolutePath)
putString("name", name)
putString("type", type)
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
}
} catch (_: Throwable) {
null
}
}
}
/**
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
*/
@ReactMethod
fun getSharedData(promise: Promise) {
try {
val activity = currentActivity
val intent = activity?.intent
if (intent == null || !isShare(intent)) {
promise.resolve(Arguments.createMap().apply {
putString("text", "")
putArray("items", Arguments.createArray())
})
return
}
val data = extract(reactContext, intent)
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
// nicht erneut oeffnet.
activity.intent = Intent(Intent.ACTION_MAIN)
promise.resolve(data)
} catch (e: Throwable) {
promise.reject("SHARE_READ_FAILED", e.message, e)
}
}
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
@ReactMethod
fun clearCache(promise: Promise) {
try {
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
promise.resolve(true)
} catch (e: Throwable) {
promise.resolve(false)
}
}
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ShareIntentPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ShareIntentModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.2",
"version": "0.2.5.4",
"private": true,
"scripts": {
"android": "react-native run-android",
+183
View File
@@ -0,0 +1,183 @@
/**
* Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt.
*
* Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt
* oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text,
TextInput, TouchableOpacity, View,
} from 'react-native';
import brainApi, { Project } from '../services/brainApi';
export interface SharedItem {
path: string;
name: string;
type: string;
size: number;
}
interface Props {
visible: boolean;
items: SharedItem[];
/** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */
initialText?: string;
onCancel: () => void;
/** projectId: '' = Hauptchat. text: optional, darf leer sein. */
onSend: (projectId: string, text: string) => void;
}
const fmtSize = (n: number) =>
n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB`
: n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
const ShareModal: React.FC<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [pid, setPid] = useState<string | null>(null); // null = noch nichts gewaehlt
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (!visible) return;
setPid(null);
setText(initialText || '');
setSending(false);
setLoading(true);
brainApi.listProjects(false)
.then(ps => setProjects(ps || []))
.catch(() => setProjects([]))
.finally(() => setLoading(false));
}, [visible, initialText]);
const send = useCallback(() => {
if (pid === null || sending) return;
setSending(true);
onSend(pid, text.trim());
}, [pid, text, sending, onSend]);
const preview = items.find(i => i.type?.startsWith('image/'));
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
<View style={s.backdrop}>
<View style={s.box}>
<Text style={s.title}>Mit ARIA teilen</Text>
{/* Was wird geteilt */}
<View style={s.itemsBox}>
{preview ? (
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
) : null}
<View style={{ flex: 1 }}>
{items.length === 0 ? (
<Text style={s.dim}>Nur Text</Text>
) : items.slice(0, 3).map((it, i) => (
<Text key={i} style={s.itemLine} numberOfLines={1}>
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
</Text>
))}
{items.length > 3 ? (
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
) : null}
</View>
</View>
{/* Wohin */}
<Text style={s.label}>Wohin soll das?</Text>
{loading ? (
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
) : (
<FlatList
style={s.list}
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
keyExtractor={p => p.id || '__main__'}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
const sel = pid === item.id;
return (
<TouchableOpacity
style={[s.row, sel && s.rowSel]}
onPress={() => setPid(item.id)}>
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
{item.id ? '📁 ' : '💬 '}{item.name}
</Text>
{sel ? <Text style={s.check}>✓</Text> : null}
</TouchableOpacity>
);
}}
/>
)}
{/* Optionaler Text */}
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
<TextInput
style={s.input}
value={text}
onChangeText={setText}
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
placeholderTextColor="#666680"
multiline
/>
<View style={s.actions}>
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
<Text style={s.btnGhostText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
onPress={send}
disabled={pid === null || sending}>
<Text style={s.btnText}>
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
box: {
backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16,
padding: 18, maxHeight: '88%',
},
title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 },
itemsBox: {
flexDirection: 'row', gap: 10, alignItems: 'center',
backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14,
},
thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' },
itemLine: { color: '#E0E0F0', fontSize: 13 },
dim: { color: '#8888AA', fontSize: 12 },
label: { color: '#8888AA', fontSize: 12, marginBottom: 6 },
list: { maxHeight: 190, marginBottom: 14 },
row: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 10,
paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E',
},
rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' },
rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 },
rowTextSel: { color: '#3FFF9F', fontWeight: '600' },
check: { color: '#3FFF9F', fontSize: 16 },
input: {
backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0',
paddingHorizontal: 12, paddingVertical: 10, minHeight: 56,
textAlignVertical: 'top', marginBottom: 16, fontSize: 14,
},
actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
btnGhost: { paddingVertical: 11, paddingHorizontal: 18 },
btnGhostText: { color: '#8888AA', fontSize: 15 },
btn: {
backgroundColor: '#FFD60A', borderRadius: 8,
paddingVertical: 11, paddingHorizontal: 24,
},
btnDisabled: { backgroundColor: '#3A3A2A' },
btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' },
});
export default ShareModal;
+149 -19
View File
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import ShareModal, { SharedItem } from '../components/ShareModal';
import shareIntent from '../services/shareIntent';
import MessageText from '../components/MessageText';
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
@@ -198,6 +200,28 @@ function formatDur(sec: number): string {
return `${m}:${r.toString().padStart(2, '0')}`;
}
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
//
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
//
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
//
// Darum zwei Grenzen:
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
// Dokumente passen rein, Videos nicht.
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
// Meldung statt eines Absturzes.
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -348,6 +372,10 @@ const ChatScreen: React.FC = () => {
} | null>(null);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
// Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden.
const [shareItems, setShareItems] = useState<SharedItem[]>([]);
const [shareText, setShareText] = useState('');
const [shareVisible, setShareVisible] = useState(false);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
@@ -419,6 +447,33 @@ const ChatScreen: React.FC = () => {
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
const autoOpenPaths = useRef<Set<string>>(new Set());
/**
* Re-Download anfordern — mit Groessen-Bremse.
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
*/
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
if (!att.serverPath) {
if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
}
return false;
}
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
if (Platform.OS === 'android') {
ToastAndroid.show(
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
+ `Liegt auf dem Server unter ${att.serverPath}`,
ToastAndroid.LONG,
);
}
return false;
}
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
return true;
}, []);
// Eindeutige Message-ID generieren
const nextId = (): string => {
messageIdCounter.current += 1;
@@ -854,13 +909,26 @@ const ChatScreen: React.FC = () => {
setTimeout(() => {
setMessages(prev => {
const missing: {id: string, serverPath: string}[] = [];
const skipped: string[] = [];
for (const msg of prev) {
for (const att of msg.attachments || []) {
if (att.serverPath && !att.uri) {
missing.push({ id: msg.id, serverPath: att.serverPath });
if (!att.serverPath || att.uri) continue;
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
// Sie bleiben als "tippen zum Laden" stehen — der User
// entscheidet, ob er sie wirklich aufs Handy zieht.
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
skipped.push(`${att.name} (${fmtMB(att.size)})`);
continue;
}
missing.push({ id: msg.id, serverPath: att.serverPath });
}
}
if (skipped.length > 0) {
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
import('../services/logger')
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
.catch(() => {});
}
if (missing.length > 0) {
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
for (const m of missing) {
@@ -2471,16 +2539,47 @@ const ChatScreen: React.FC = () => {
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
}, []);
// ── Teilen-Ziel ────────────────────────────────────────────────
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
// (App laeuft schon → onNewIntent im MainActivity).
useEffect(() => {
let alive = true;
const open = (p: { text: string; items: SharedItem[] }) => {
if (!alive) return;
if (!p.items.length && !p.text) return; // nichts dabei
setShareItems(p.items);
setShareText(p.text);
setShareVisible(true);
};
shareIntent.getShared().then(open).catch(() => {});
const unsub = shareIntent.onShare(open);
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
const appSub = AppState.addEventListener('change', st => {
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
});
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
}, []);
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const sendPendingAttachments = useCallback(async (
messageText: string,
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
explicitAttachments?: { file: any; isPhoto: boolean }[],
) => {
const atts = explicitAttachments ?? pendingAttachments;
if (atts.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
const location = await getCurrentLocation();
const msgId = nextId();
// Alle Attachments fuer die Chat-Nachricht sammeln
const attachments: Attachment[] = [];
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
@@ -2503,7 +2602,7 @@ const ChatScreen: React.FC = () => {
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
text: messageText || `${atts.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments,
projectId: activePid,
@@ -2516,7 +2615,7 @@ const ChatScreen: React.FC = () => {
setMessages(prev => capMessages([...prev, userMsg]));
// Alle Dateien an RVS senden + auf Disk speichern
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
@@ -2571,6 +2670,33 @@ const ChatScreen: React.FC = () => {
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
const handleShareSend = useCallback(async (projectId: string, text: string) => {
try {
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
// steht nach dem Senden gleich im richtigen Kontext.
setFocusedProjectId(projectId);
focusedProjectIdRef.current = projectId;
const files = await shareIntent.toFileData(shareItems);
if (files.length) {
// Liste explizit uebergeben: der State waere hier noch leer.
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
} else if (text) {
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
setInputText(text);
}
shareIntent.clearShareCache();
} catch (e) {
console.warn('[Share] Senden fehlgeschlagen:', e);
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
} finally {
setShareVisible(false);
setShareItems([]);
setShareText('');
}
}, [shareItems, sendPendingAttachments]);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
@@ -2805,16 +2931,15 @@ const ChatScreen: React.FC = () => {
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
if (att.serverPath) {
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
onPress={() => { requestAttachment(att, item.id); }}
>
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentFileSize}>
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
{!att.serverPath ? '(nicht verfuegbar)'
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
? `(${fmtMB(att.size)} — tippen zum Laden)`
: '(tippen zum Laden)'}
</Text>
</TouchableOpacity>
) : att.type === 'audio' ? (
@@ -2851,11 +2976,9 @@ const ChatScreen: React.FC = () => {
}
// Re-Download via file_request \u2192 bei file_response wird die
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
if (att.serverPath) {
autoOpenPaths.current.add(att.serverPath);
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
} else if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
if (!requestAttachment(att, item.id) && att.serverPath) {
autoOpenPaths.current.delete(att.serverPath);
}
}}
>
@@ -3708,6 +3831,13 @@ const ChatScreen: React.FC = () => {
{/* Datei-Upload Modal */}
<Modal visible={showFileUpload} transparent animationType="slide">
<View style={styles.modalOverlay}>
<ShareModal
visible={shareVisible}
items={shareItems}
initialText={shareText}
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
onSend={handleShareSend}
/>
<FileUpload
onFileSelected={handleFileSelected}
onCancel={() => setShowFileUpload(false)}
+6 -2
View File
@@ -1161,8 +1161,12 @@ class AudioService {
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
if (rvs.getState() !== 'connected') {
console.warn('[Audio] startStreamingRecording: RVS nicht verbunden (state=%s)',
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
// Nutzer kann gar nicht mehr sprechen.
if (!rvs.isSocketOpen()) {
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
rvs.getState());
return { requestId: '', ok: false, error: 'not-connected' };
}
+8 -1
View File
@@ -49,8 +49,15 @@ const BREADCRUMB_KEY = 'aria_last_breadcrumb';
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
let _breadcrumbDirty = false;
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
*
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
export function noteBreadcrumb(scope: string, message: string): void {
if (scope === 'app.crash-detected') return;
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
_breadcrumbDirty = true;
}
+9
View File
@@ -130,6 +130,15 @@ class RVSConnection {
this.setState('disconnected');
}
/**
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
*/
isSocketOpen(): boolean {
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
}
/** Nachricht an den RVS senden */
send(type: MessageType, payload: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+77
View File
@@ -0,0 +1,77 @@
/**
* Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul.
*
* Zwei Faelle:
* App war ZU → der Inhalt liegt beim Start am Intent → getShared()
* App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare()
*
* Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert
* echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr
* lesbar (die Leseerlaubnis gilt nur fuer den Intent).
*/
import { NativeEventEmitter, NativeModules } from 'react-native';
import RNFS from 'react-native-fs';
import type { FileData } from '../components/FileUpload';
import type { SharedItem } from '../components/ShareModal';
const { ShareIntent } = NativeModules;
export interface SharedPayload {
text: string;
items: SharedItem[];
}
const EMPTY: SharedPayload = { text: '', items: [] };
/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */
export async function getShared(): Promise<SharedPayload> {
if (!ShareIntent?.getSharedData) return EMPTY;
try {
const r = await ShareIntent.getSharedData();
return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] };
} catch (e) {
console.warn('[Share] getSharedData fehlgeschlagen:', e);
return EMPTY;
}
}
/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */
export function onShare(cb: (p: SharedPayload) => void): () => void {
if (!ShareIntent) return () => {};
const emitter = new NativeEventEmitter(ShareIntent);
const sub = emitter.addListener('ShareReceived', (r: any) => {
cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] });
});
return () => { try { sub.remove(); } catch (_) {} };
}
/** Zwischenkopien loeschen (nach erfolgreichem Senden). */
export async function clearShareCache(): Promise<void> {
try { await ShareIntent?.clearCache?.(); } catch (_) {}
}
/**
* Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg
* erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die
* Bueroklammer, inklusive Anhang-Darstellung im Chat.
*/
export async function toFileData(items: SharedItem[]): Promise<FileData[]> {
const out: FileData[] = [];
for (const it of items) {
try {
const base64 = await RNFS.readFile(it.path, 'base64');
out.push({
name: it.name || 'geteilt',
type: it.type || 'application/octet-stream',
size: it.size || 0,
base64,
uri: 'file://' + it.path,
});
} catch (e) {
console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e);
}
}
return out;
}
export default { getShared, onShare, clearShareCache, toFileData };
+20 -6
View File
@@ -3550,6 +3550,10 @@ class ARIABridge:
elif msg_type == "sat_hello":
sid = (payload.get("id") or "").strip()
if sid:
# sat_hello kommt alle ~25s erneut. Nur Neuanmeldungen und echte
# Aenderungen loggen — sonst ersaeuft das Bridge-Log in
# "Satellit online", und die interessanten Zeilen sind weg.
prev = self._satellites.get(sid)
self._satellites[sid] = {
"id": sid,
"location": payload.get("location") or sid,
@@ -3557,9 +3561,12 @@ class ARIABridge:
"control": bool(payload.get("control")),
"last_seen": time.time(),
}
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
sid, self._satellites[sid]["location"],
self._satellites[sid]["caps"], self._satellites[sid]["control"])
cur = self._satellites[sid]
if prev is None or any(prev.get(k) != cur.get(k)
for k in ("location", "caps", "control")):
logger.info("[sat] Satellit %s: %s (%s) caps=%s control=%s",
"online" if prev is None else "aktualisiert",
sid, cur["location"], cur["caps"], cur["control"])
return
elif msg_type == "host_hello":
@@ -3625,9 +3632,16 @@ class ARIABridge:
"busy": bool(prev.get("busy", False)),
"last_seen": time.time(),
}
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
iid, self._workers[iid]["service"], self._workers[iid]["node"],
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
# Wie bei sat_hello: worker_hello wiederholt sich im Takt von
# ~30s. Nur neue Boxen und echte Aenderungen (anderes Modell,
# andere GPUs) ins Log — der Rest ist Rauschen.
cur = self._workers[iid]
if not prev or any(prev.get(k) != cur.get(k)
for k in ("service", "node", "gpus", "model", "models")):
logger.info("[worker] %s: %s (service=%s node=%s gpus=%s model=%s)",
"online" if not prev else "aktualisiert",
iid, cur["service"], cur["node"],
cur["gpus"] or "?", cur["model"] or "?")
return
elif msg_type == "stt_lease_request":
+106
View File
@@ -124,6 +124,86 @@ const ALLOWED_TYPES = new Set([
// Token-Raum: token -> { clients: Set<ws> }
const rooms = new Map();
// ── Punkt-zu-Punkt-Antworten ────────────────────────────────────────
//
// Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum
// jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als
// base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die
// STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und
// die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht
// kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz,
// aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt.
//
// Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit
// derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie
// bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss,
// dessen Anfrage der RVS nie gesehen hat).
const TARGETED = new Map([
["file_request", "file_response"],
["file_zip_request", "file_zip_response"],
["file_version_download_request", "file_version_download_response"],
["brain_request", "brain_response"],
]);
const TARGETED_REPLIES = new Set(TARGETED.values());
// `antwortTyp \0 requestId` -> { clients: Set<ws>, at }
// Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000;
function pendingKey(replyType, requestId) {
return `${replyType}${requestId}`;
}
/** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */
function rememberRequester(ws, msg) {
const replyType = TARGETED.get(msg.type);
if (!replyType) return;
const rid = msg.payload && msg.payload.requestId;
if (!rid || typeof rid !== "string") return;
const key = pendingKey(replyType, rid);
let entry = pendingReplies.get(key);
if (!entry) {
entry = { clients: new Set(), at: Date.now() };
pendingReplies.set(key, entry);
}
entry.clients.add(ws);
entry.at = Date.now();
}
/**
* Empfaenger fuer eine Antwort-Nachricht.
* null = keine gezielte Antwort → normal broadcasten.
* [] = der Fragende ist weg → verwerfen (NICHT broadcasten, sonst waere
* der ganze Sinn weg und die 200 MB gingen doch an alle).
*/
function targetedRecipients(msg) {
if (!TARGETED_REPLIES.has(msg.type)) return null;
const rid = msg.payload && msg.payload.requestId;
if (!rid || typeof rid !== "string") return null;
const key = pendingKey(msg.type, rid);
const entry = pendingReplies.get(key);
if (!entry) return null;
pendingReplies.delete(key);
return [...entry.clients].filter((c) => c.readyState === 1);
}
/** Abgelaufene/tote Eintraege wegwerfen (laeuft mit cleanupRooms). */
function cleanupPendingReplies() {
const now = Date.now();
for (const [key, entry] of pendingReplies) {
if (now - entry.at > PENDING_TTL_MS) {
pendingReplies.delete(key);
continue;
}
for (const c of entry.clients) {
if (c.readyState > 1) entry.clients.delete(c);
}
if (entry.clients.size === 0) pendingReplies.delete(key);
}
}
// ── Hilfsfunktionen ─────────────────────────────────────────────────
function timestamp() {
@@ -147,6 +227,7 @@ function cleanupRooms() {
log(`Leerer Raum entfernt: ${token.slice(0, 8)}...`);
}
}
cleanupPendingReplies();
}
// ── HTTP + WebSocket Server (hybrid) ────────────────────────────────
@@ -421,6 +502,28 @@ function registerClient(ws, token) {
return;
}
// Anfrage merken, damit die Antwort gleich gezielt zurueckgehen kann
rememberRequester(ws, msg);
// Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum.
const targets = targetedRecipients(msg);
if (targets) {
const data = raw.toString();
let sent = 0;
for (const client of targets) {
if (client !== ws) { client.send(data); sent++; }
}
// Grosse Payloads mitloggen: hier spart der RVS am meisten ein, und
// wenn mal eine Antwort verschwindet will man genau das hier sehen.
if (raw.length > 4 * 1024 * 1024 || sent === 0) {
const mb = (raw.length / 1024 / 1024).toFixed(1);
log(sent === 0
? `${msg.type}: Fragender nicht mehr verbunden — ${mb}MB verworfen`
: `${msg.type}: ${mb}MB gezielt an ${sent} Client(s) statt an ${room.clients.size - 1}`);
}
return;
}
// An alle anderen Clients im Raum weiterleiten
for (const client of room.clients) {
if (client !== ws && client.readyState === 1) {
@@ -431,6 +534,9 @@ function registerClient(ws, token) {
ws.on("close", () => {
room.clients.delete(ws);
for (const [key, entry] of pendingReplies) {
if (entry.clients.delete(ws) && entry.clients.size === 0) pendingReplies.delete(key);
}
log(`Client getrennt: ${token.slice(0, 8)}... (${room.clients.size} verbleibend)`);
if (room.clients.size === 0) {
rooms.delete(token);
+8 -1
View File
@@ -896,7 +896,14 @@ async def run_loop(runner: F5Runner) -> None:
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
+8 -1
View File
@@ -534,7 +534,14 @@ async def _run() -> None:
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
+8 -1
View File
@@ -760,7 +760,14 @@ async def run_loop(sessions: SessionManager) -> None:
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
+8 -1
View File
@@ -898,7 +898,14 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws: