Compare commits

...

14 Commits

Author SHA1 Message Date
duffyduck 5a7bfd9f50 release: bump version to 0.2.0.2 2026-07-06 10:30:39 +02:00
duffyduck 5410371b9c fix(voice): App uebernimmt Server-projectId der STT-Bubble — App/Diagnostic-Sync
Symptom: Frage im Projekt gestellt, Antwort nur in der App sichtbar, Frage nur
in der Diagnostic — Frage und Antwort scheinbar in verschiedenen Kontexten.

Ursache: die App taggt die lokale Voice-Bubble optimistisch mit dem App-Focus,
waehrend der Server (Voice-Router) die autoritative Zuordnung macht. Weichen
die ab (Registry-Race aus 3e88eec, Sticky, „fuer X:"-Prefix, Fallback), landet
die lokale App-Bubble in einem anderen Kontext als die serverseitig getaggte
Frage/Antwort → App- und Diagnostic-Ansicht divergieren.

Fix: beim Eintreffen des sender=stt-Broadcasts uebernimmt die App die
projectId des Servers fuer die Bubble (alle drei Match-Zweige). Nur wenn das
Feld mitkommt — altes Bridge-Format ohne projectId → App-Focus behalten.
Primaerursache bleibt 3e88eec (Router liefert wieder den korrekten Kontext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:28:35 +02:00
duffyduck b27fba316b release: bump version to 0.2.0.1 2026-07-06 00:34:43 +02:00
duffyduck 648698d202 fix(voice): robusteres STT-Endpointing via akustische Stille (nicht nur semantisch)
Bug: die Spracherkennung bricht mal zu frueh ab (nach zwei Worten obwohl noch
gesprochen wird) und merkt mal gar nicht dass man aufgehoert hat.

Ursache: der Endpointer feuerte rein auf semantischer Stagnation (Whisper-
Transkript waechst endpoint_ms lang nicht). Das ist fragil — kurze Sprech-
Pausen oder beam_size=1-Instabilitaet → vorzeitiger Cut; Oszillation/
Halluzination → nie ein Endpoint.

Fix: akustische Stille als robustes Primaersignal. Jeder Tick (~200ms) misst
die RMS-Energie der letzten 300ms:
- Solange Sprach-Energie da ist, bleibt die Session am Leben (kein Cut mitten
  im Reden).
- Endpoint feuert wenn seit endpoint_ms keine Sprach-Energie mehr da ist.
- Semantischer Backstop (2x endpoint_ms) fuer laute Umgebungen (Auto), wo die
  Energie nie faellt — dort degradiert es sauber aufs bisherige Verhalten.

RMS-Threshold (0.012) gegen Stille/Sprache/Fahrgeraeusch verifiziert.
Konstanten oben in der Datei tunebar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:31:48 +02:00
duffyduck 3e88eecd9c fix(voice): Sprach-Nachricht landet im richtigen Projekt (Registry-Race)
Bug: bei manuellem Stop landete die Sprachnachricht (und ARIAs Antwort) im
Hauptchat statt im fokussierten Projekt — mal so, mal so, je nachdem ob der
Endpoint natuerlich kam oder man den Stop-Button drueckte.

Ursache: stt_stream_end popte die Focus-projectId-Registry SOFORT. Nach einem
manuellen Stop folgt aber noch der finale stt_endpoint (Whisper-Final-
Transcribe), der die projectId aus genau dieser Registry braucht. War sie schon
weg → focused_pid="" → Voice-Router faellt auf Hauptchat zurueck. Die
User-Bubble (lokal getaggt) blieb im Projekt, ARIAs Antwort kam aber mit
project_id="" → nur in EINEM Kontext sichtbar.

Fix: stt_stream_end raeumt die Registry nicht mehr sofort auf, sondern per
verzoegertem call_later(20s) als Leak-Schutz. Der stt_endpoint-Handler popt
selbst wenn er zuerst kommt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:31:48 +02:00
duffyduck e33d1c782f fix(diagnostic): ARIA-Datei-Bubbles (Bilder) tragen project_id — kein Ausblenden mehr
Regression aus dem chat_history-projectId-Fix: nach dem History-Reload laeuft
jetzt updateChatVisibilityByFocus(). addAriaFile() setzte aber kein
dataset.projectId → Datei-/Bild-Bubbles galten als Hauptchat und wurden
ausgeblendet, sobald der (aus localStorage wiederhergestellte) Focus auf einem
Projekt lag. In der App trat das nicht auf (datengetriebener Filter mit
korrektem projectId).

- addAriaFile(): setzt dataset.projectId aus p.projectId und respektiert beim
  Live-Anhaengen den aktuellen Focus (wie addChat via hiddenByFocus).
- chat_history-Renderer: reicht m.projectId an addAriaFile weiter.
- Bridge _process_core_response: haengt turn_pid als f["projectId"] an die
  file_from_aria-Payload, damit der Live-Pfad die Zuordnung kennt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:46:25 +02:00
duffyduck 1568c25ac4 fix(projects): Backfill v2 — robuster Match trotz GPS/Barge-In/FILE-Marker
v1 matchte text==content exakt und verfehlte damit Nachrichten, bei denen die
Bridge den Brain-Text anreichert oder cleant:
- User-Turns: _build_core_text PREPENDT [Hinweis: Barge-In]-/[GPS-Position]-
  Bloecke — die stehen in conversation.jsonl, nicht in chat_backup.
- Assistant-Turns: conversation enthaelt [FILE: /shared/uploads/...]-Marker,
  chat_backup hat sie schon rausgecleant.

_norm entfernt jetzt FILE-Marker + fuehrende [..]-Bloecke, kollabiert
Whitespace und vergleicht einen 120-Zeichen-Praefix. Neuer Marker (v2) →
laeuft einmal neu und fuellt die von v1 verbliebenen Luecken. Nicht-destruktiv
(eigene .pre-backfill-v2.bak), bestehende Tags bleiben unangetastet.

Mit Tests gegen GPS-/Barge-In-/FILE-Marker-Faelle verifiziert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:34:17 +02:00
duffyduck 97ee455ab4 fix(diagnostic): chat_history traegt project_id — getaggte Bubbles landen im Projekt
Der Reload-Pfad des Dashboards warf die Kontext-Zuordnung an ZWEI Stellen weg,
weshalb nach jedem Reload alles im Hauptchat lag (Projekte leer) — unabhaengig
davon ob chat_backup korrekt getaggt war:

- server.js (History-Builder): pushte {type,text,meta,ts} ohne project_id.
  Jetzt projectId aus obj.project_id fuer sent/received/aria_file mitgegeben.
- index.html (chat_history-Renderer): setzte dataset.ts aber nie
  dataset.projectId → jede Bubble galt als Hauptchat. Jetzt gesetzt, und nach
  dem Neuaufbau updateChatVisibilityByFocus() aufgerufen damit der aktuelle
  Kontext-Focus sofort greift.

Zusammen mit der Backfill-Migration (8b567e1) erscheinen alt-getaggte Turns
jetzt beim Dashboard-Reload im richtigen Projekt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:26:50 +02:00
duffyduck cd72068e76 release: bump version to 0.2.0.0 2026-07-03 02:22:58 +02:00
duffyduck 8b567e15bf feat(projects): Migration — alt-getaggte Nachrichten nachtraeglich in Projekte einsortieren
Projekt-Nachrichten aus der Zeit vor dem chat_backup-project_id-Feld (getaggt in
conversation.jsonl seit fc0f91d, aber chat_backup fuehrte project_id erst ab
f51ad15) lagen in der UI im Hauptchat statt im Projekt. Die App/Diagnostic
zeigen aus chat_backup.jsonl — dort fehlte der Tag.

Neue Einmal-Migration (Brain-Lifespan) schreibt project_id aus conversation.jsonl
per (role, text)-Match reihenfolge-erhaltend nach chat_backup.jsonl zurueck:
- idempotent via Marker /shared/config/.chat_backup_projectid_backfill_v1
- nicht-destruktiv: legt .pre-backfill-v1.bak an, setzt nur LEERE project_ids,
  entfernt/aendert nie einen bestehenden Tag
- atomar (tmp + os.replace)
- Duplikate: Deque je (role, normtext) inkl. "" fuer Hauptthread → korrekte
  Zuordnung auch bei wiederholten Texten, kein faelschliches Taggen von
  Hauptchat-Interleaving

Mit Logik-Tests (Zuordnung, Duplikat-Reihenfolge, Idempotenz) verifiziert.
Nachrichten aus der Zeit bevor es Projekte gab bleiben untagged im Hauptchat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:20:41 +02:00
duffyduck 64c06db308 fix(diagnostic): keine untagged ARIA-Bubbles/Backup-Writes mehr (leere Projekte)
Das Dashboard hatte dieselbe Ursache wie die App (untagged Nachrichten in
chat_backup) PLUS zwei eigene untagged-Pfade ueber den Gateway-Watch:

- Frontend: chat_final rendert keine Chat-Bubble mehr. ARIA-Antworten kommen
  ausschliesslich via rvs_chat (traegt projectId). chat_final stammt vom
  Gateway-Watch ohne projectId → erzeugte eine Duplikat-Bubble im Hauptchat.
- server.js: kein chat_backup-Write im Gateway-final-Handler mehr. Die Bridge
  (_process_core_response) ist massgeblicher Writer und schreibt MIT project_id;
  der Write hier erzeugte untagged Duplikate, die beim Reload im Hauptchat
  statt im Projekt landeten.

Der App-Focus-Fix (63dde65) sorgt dafuer dass neue Nachrichten ueberhaupt
getaggt in chat_backup landen — davon profitiert das Dashboard direkt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:15:02 +02:00
duffyduck 63dde6506f fix(projects): Drawer resettet App-Focus nicht mehr (leere Projekte-Ursache) + Zurueck-Button
Kernbug: ProjectsBrowser.load() pushte bei JEDEM Drawer-Oeffnen status.active
in den App-Focus. Im Multi-Threading hat das Brain keinen globalen
active_project-State mehr → status.active ist null → Focus wurde auf Hauptchat
zurueckgesetzt. Folge: nach jedem Drawer-Oeffnen landeten alle Nachrichten im
Hauptchat, Projekte blieben leer.

- ProjectsBrowser: neues Prop currentFocusId (App-Focus = Source-of-Truth).
  load() uebernimmt nur noch die Projektliste, kein onActiveChanged(status.active)
  mehr. Highlight (✓ FOCUS) folgt currentFocusId.
- ChatScreen: currentFocusId={focusedProjectId} durchgereicht.
- ChatScreen: direkter „← Hauptchat"-Button im Focus-Header (nur im Projekt
  sichtbar) — ein Tap statt Drawer→Hauptchat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:07:33 +02:00
duffyduck 5fb08b4ea5 release: bump version to 0.1.9.9 2026-07-03 01:53:25 +02:00
duffyduck d49ec64e27 fix(voice-router): Voice folgt App-Focus + „hauptmenü" als back-to-main
Zwei Bugs aus dem ersten Live-Test des Multi-Threading-Designs.

Bug 1 — Voice ignorierte App-Focus:
Stefan hat in Projekt X reingeguckt und was reingesagt — Message landete
im Hauptchat statt in X. Der Voice-Router auf der Bridge kannte den
sichtbaren Kontext der App nicht.

Fix:
- audio.ts.startStreamingRecording nimmt neuen opts.projectId und
  schickt es im stt_stream_start-Payload mit.
- ChatScreen.tsx: alle 4 startStreamingRecording-Callsites (wake,
  barge-in, passive, manuell) uebergeben focusedProjectIdRef.current.
  Neuer useRef-Spiegel damit die Focus-ID auch in useCallbacks/
  useEffects mit alten Closures aktuell bleibt.
- aria_bridge.py: neuer Handler fuer stt_stream_start speichert die
  projectId in self._stt_stream_projects[requestId], stt_stream_end
  loescht wieder. Beim stt_endpoint wird sie an _process_endpoint_text
  weitergereicht und dort als default_project_id in den Voice-Router.
- _apply_voice_router bekommt neuen Prio-Rank 4: „App-Focus als
  Default" — greift wenn kein Meta, kein Prefix und kein aktiver Sticky.
  So folgt Voice ohne extra Marker dem sichtbaren Kontext.

Bug 2 — Back-to-Main-Regex zu eng:
„zurück ins hauptmenü" wurde nicht als Meta erkannt (Regex matchte nur
„zurück zum hauptchat") und landete deshalb im aktiven Sticky-Projekt.

Fix: Regex akzeptiert jetzt auch hauptmenü, menü, haupt, main mit
Praepositionen „zum/zur/ins/in den".

Bonus — Burger-Button heller:
Stefan konnte den ☰-Toggle im Header kaum sehen. Farbe von Default
(dunkelgrau) auf #E0E0F0 (hell) mit fontWeight 700 gesetzt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 01:50:50 +02:00
11 changed files with 424 additions and 39 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 10908 versionCode 20002
versionName "0.1.9.8" versionName "0.2.0.2"
// 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.9.8", "version": "0.2.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+15 -3
View File
@@ -36,6 +36,12 @@ interface Props {
/** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale /** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale
* UI-Entscheidung, wechselt den Chat-Focus). */ * UI-Entscheidung, wechselt den Chat-Focus). */
onActiveChanged?: (project: Project | null) => void; onActiveChanged?: (project: Project | null) => void;
/** Der aktuell in der App fokussierte Kontext (App-lokale Source-of-Truth).
* Leer = Hauptchat. Steuert das ✓-FOCUS-Highlight. WICHTIG: der Drawer darf
* den Focus NICHT aus dem Brain-Status ableiten — im Multi-Threading gibt es
* kein globales active_project mehr (status.active ist null), das wuerde den
* Focus bei jedem Drawer-Oeffnen auf Hauptchat zuruecksetzen. */
currentFocusId?: string;
/** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id). /** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id).
* Wenn geliefert: Status-Dot pro Zeile gerendert. */ * Wenn geliefert: Status-Dot pro Zeile gerendert. */
queueStatus?: Record<string, { busy: boolean; queue_size: number }>; queueStatus?: Record<string, { busy: boolean; queue_size: number }>;
@@ -51,7 +57,7 @@ function _fmtRel(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleDateString('de-DE'); return new Date(unixSec * 1000).toLocaleDateString('de-DE');
} }
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged, queueStatus }) => { export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged, currentFocusId, queueStatus }) => {
const _statusDot = (pid: string) => { const _statusDot = (pid: string) => {
const s = queueStatus?.[pid]; const s = queueStatus?.[pid];
if (!s) return { color: '#555570', label: '' }; if (!s) return { color: '#555570', label: '' };
@@ -80,9 +86,11 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
setLoading(true); setErr(null); setLoading(true); setErr(null);
brainApi.getProjectStatus() brainApi.getProjectStatus()
.then(status => { .then(status => {
// NUR die Projektliste + Queue uebernehmen. NICHT status.active in den
// App-Focus pushen — im Multi-Threading ist das Brain-active_project
// bedeutungslos (null), das wuerde den Focus bei jedem Drawer-Oeffnen
// auf Hauptchat zuruecksetzen und alle Nachrichten dort landen lassen.
setProjects(status.projects || []); setProjects(status.projects || []);
setActiveId(status.active_id || '');
onActiveChangedRef.current?.(status.active);
}) })
.catch(e => setErr(String(e?.message || e))) .catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -90,6 +98,10 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
useEffect(() => { if (visible) load(); }, [visible, load]); useEffect(() => { if (visible) load(); }, [visible, load]);
// Highlight („✓ FOCUS") folgt dem App-Focus (Source-of-Truth), nicht dem
// Brain. switchTo setzt activeId zusaetzlich sofort fuer Instant-Feedback.
useEffect(() => { setActiveId(currentFocusId || ''); }, [currentFocusId]);
// Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig // Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
+42 -4
View File
@@ -499,8 +499,14 @@ const ChatScreen: React.FC = () => {
// fuer den Auto-Fall angenehm. // fuer den Auto-Fall angenehm.
useEffect(() => { useEffect(() => {
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {}); AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
focusedProjectIdRef.current = focusedProjectId;
}, [focusedProjectId]); }, [focusedProjectId]);
// Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
// die sich bei jedem Wechsel).
const focusedProjectIdRef = useRef<string>('');
// Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und // Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und
// fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout). // fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout).
useEffect(() => { useEffect(() => {
@@ -1054,6 +1060,17 @@ const ChatScreen: React.FC = () => {
if (sender === 'stt') { if (sender === 'stt') {
const sttText = (message.payload.text as string) || ''; const sttText = (message.payload.text as string) || '';
const sttAudioReqId = (message.payload.audioRequestId as string) || ''; const sttAudioReqId = (message.payload.audioRequestId as string) || '';
// Autoritative Projekt-Zuordnung vom Server (Voice-Router). Die App
// hatte die lokale Bubble optimistisch mit dem App-Focus getaggt;
// wenn der Router anders entschieden hat (Sticky, \u201Efuer X:"-Prefix,
// oder Fallback), uebernehmen wir hier den Server-Wert \u2014 sonst
// divergieren App- und Diagnostic-Ansicht (Frage im einen Kontext,
// Antwort im anderen). Nur uebernehmen wenn das Feld mitgeliefert
// wurde (leer/undefined = altes Bridge-Format \u2192 App-Focus behalten).
const hasServerPid = typeof (message.payload as any).projectId === 'string';
const sttProjectId = ((message.payload as any).projectId as string) || '';
const applyPid = (m: ChatMessage): ChatMessage =>
hasServerPid ? { ...m, projectId: sttProjectId } : m;
if (!sttText) { if (!sttText) {
return; return;
} }
@@ -1066,7 +1083,7 @@ const ChatScreen: React.FC = () => {
const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId); const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId);
if (idxById >= 0) { if (idxById >= 0) {
const next = prev.slice(); const next = prev.slice();
next[idxById] = { ...next[idxById], text: newText }; next[idxById] = applyPid({ ...next[idxById], text: newText });
return next; return next;
} }
} }
@@ -1077,7 +1094,7 @@ const ChatScreen: React.FC = () => {
); );
if (idx >= 0) { if (idx >= 0) {
const next = prev.slice(); const next = prev.slice();
next[idx] = { ...next[idx], text: newText }; next[idx] = applyPid({ ...next[idx], text: newText });
return next; return next;
} }
// Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen // Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen
@@ -1087,6 +1104,7 @@ const ChatScreen: React.FC = () => {
text: newText, text: newText,
timestamp: message.timestamp, timestamp: message.timestamp,
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }], attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
projectId: hasServerPid ? sttProjectId : focusedProjectIdRef.current,
}]); }]);
}); });
return; return;
@@ -1414,6 +1432,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: windowMs,
endpointMs: 1500, endpointMs: 1500,
hardCapMs: 60000, hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
}); });
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{}); import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
if (ok) { if (ok) {
@@ -1509,6 +1528,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: windowMs,
endpointMs: 1500, endpointMs: 1500,
hardCapMs: 60000, hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
}); });
if (ok) { if (ok) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT); ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
@@ -1565,6 +1585,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: Math.min(passiveMs, 30000), noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: 1500, endpointMs: 1500,
hardCapMs: Math.max(passiveMs + 5000, 35000), hardCapMs: Math.max(passiveMs + 5000, 35000),
projectId: focusedProjectIdRef.current,
}); });
if (!ok) { if (!ok) {
console.warn('[Chat] passive streaming start failed — exit passive listening'); console.warn('[Chat] passive streaming start failed — exit passive listening');
@@ -1880,7 +1901,7 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation(); const location = await getCurrentLocation();
const cmid = nextClientMsgId(); const cmid = nextClientMsgId();
const activePid = focusedProjectId; const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextId(), id: nextId(),
sender: 'user', sender: 'user',
@@ -1963,6 +1984,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: 0, noSpeechTimeoutMs: 0,
endpointMs: 1500, endpointMs: 1500,
hardCapMs: 300000, hardCapMs: 300000,
projectId: focusedProjectIdRef.current,
}); });
if (!ok) { if (!ok) {
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg. // Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
@@ -2584,7 +2606,7 @@ const ChatScreen: React.FC = () => {
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
hitSlop={{top:6,bottom:6,left:6,right:6}} hitSlop={{top:6,bottom:6,left:6,right:6}}
> >
<Text style={{ fontSize: 20 }}></Text> <Text style={{ fontSize: 22, color: '#E0E0F0', fontWeight: '700' }}></Text>
{otherActive > 0 && ( {otherActive > 0 && (
<View style={{ backgroundColor: '#FF6E6E', borderRadius: 8, minWidth: 16, height: 16, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }}> <View style={{ backgroundColor: '#FF6E6E', borderRadius: 8, minWidth: 16, height: 16, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{otherActive}</Text> <Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{otherActive}</Text>
@@ -2597,6 +2619,21 @@ const ChatScreen: React.FC = () => {
</Text> </Text>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} /> <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
<Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text> <Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
{/* Direkter Zurueck-zum-Hauptchat-Button — nur wenn man in einem
Projekt ist. Ein Tap statt Drawer→Hauptchat. */}
{!isMain && (
<TouchableOpacity
onPress={() => setFocusedProjectId('')}
hitSlop={{top:8,bottom:8,left:8,right:8}}
style={{
marginLeft: 4, paddingHorizontal: 10, paddingVertical: 4,
borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.12)',
flexDirection: 'row', alignItems: 'center', gap: 4,
}}
>
<Text style={{ color: '#E0E0F0', fontSize: 12, fontWeight: '700' }}> Hauptchat</Text>
</TouchableOpacity>
)}
</View> </View>
</View> </View>
); );
@@ -2607,6 +2644,7 @@ const ChatScreen: React.FC = () => {
visible={projectsVisible} visible={projectsVisible}
onClose={() => setProjectsVisible(false)} onClose={() => setProjectsVisible(false)}
onActiveChanged={(p) => setFocusedProjectId(p?.id || '')} onActiveChanged={(p) => setFocusedProjectId(p?.id || '')}
currentFocusId={focusedProjectId}
queueStatus={queueStatus} queueStatus={queueStatus}
/> />
+5
View File
@@ -982,6 +982,10 @@ class AudioService {
noSpeechTimeoutMs?: number; noSpeechTimeoutMs?: number;
endpointMs?: number; endpointMs?: number;
hardCapMs?: number; hardCapMs?: number;
/** Focused projectId — Bridge nutzt das als Default fuer den Voice-Router.
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
}): Promise<{ requestId: string; ok: boolean }> { }): Promise<{ requestId: string; ok: boolean }> {
if (this.recordingState !== 'idle') { if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState); console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
@@ -1055,6 +1059,7 @@ class AudioService {
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500, endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000, hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000, sampleRate: 16000,
projectId: opts.projectId || '',
}); });
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer. // No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
+11
View File
@@ -112,6 +112,17 @@ async def lifespan(app: FastAPI):
except Exception as exc: except Exception as exc:
logger.warning("Lifespan: spotify fast_patterns Migration: %s", exc) logger.warning("Lifespan: spotify fast_patterns Migration: %s", exc)
# Einmalige Migration: project_id aus conversation.jsonl nach chat_backup.jsonl
# zurueckschreiben, damit alt-getaggte Projekt-Nachrichten (getaggt bevor
# chat_backup project_id fuehrte) in der UI wieder im richtigen Projekt
# landen. Idempotent (Marker), nicht-destruktiv (.bak), atomar.
try:
import migrate_backfill_projectid
res = migrate_backfill_projectid.run()
logger.info("Lifespan: chat_backup project_id Backfill: %s", res)
except Exception as exc:
logger.warning("Lifespan: project_id Backfill Migration: %s", exc)
task = asyncio.create_task(background_mod.run_loop(agent)) task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet") logger.info("Lifespan: Trigger-Loop gestartet")
try: try:
+171
View File
@@ -0,0 +1,171 @@
"""Einmalige Migration: project_id aus conversation.jsonl nach chat_backup.jsonl
zurueckschreiben.
Hintergrund: Seit es Projekte gibt (fc0f91d) taggt das Brain jeden Turn in
conversation.jsonl mit project_id. chat_backup.jsonl (die Anzeige-Quelle fuer
App + Diagnostic) bekam project_id aber erst spaeter (f51ad15). Alle Projekt-
Nachrichten aus dem Zeitfenster dazwischen liegen daher in conversation.jsonl
korrekt getaggt, in chat_backup.jsonl aber untagged → die UI zeigt sie im
Hauptchat statt im Projekt.
Diese Migration matcht chat_backup-Eintraege gegen conversation-Turns ueber
(role, text) in Reihenfolge und traegt die fehlende project_id nach. Sie ist:
- idempotent (Marker-Datei, laeuft genau einmal),
- nicht-destruktiv (legt .bak an, aendert nur LEERE project_ids, entfernt nie
einen bestehenden Tag),
- atomar (tmp-Datei + os.replace).
Reihenfolge-erhaltend: pro (role, normalisiertem Text) wird eine Deque der
project_ids aus conversation.jsonl aufgebaut (inklusive "" fuer Hauptthread-
Turns), damit wiederholte identische Texte ihre jeweils richtige Zuordnung
bekommen und Hauptchat-Interleaving nicht faelschlich getaggt wird.
"""
from __future__ import annotations
import json
import logging
import os
import re
from collections import defaultdict, deque
from pathlib import Path
logger = logging.getLogger("aria.migrate.backfill_projectid")
CONVERSATION_FILE = Path(os.environ.get("CONVERSATION_FILE", "/data/conversation.jsonl"))
CHAT_BACKUP_FILE = Path(os.environ.get("CHAT_BACKUP_FILE", "/shared/config/chat_backup.jsonl"))
# v2: robusterer Match (Marker-Strip + Praefix). v1 verlangte exakte Gleichheit
# von text==content und verfehlte damit alle Nachrichten bei denen die Bridge
# den Brain-Text anreichert (GPS/Barge-In-Hints prepended) oder cleant
# (FILE-Marker entfernt). Neuer Marker → laeuft einmal neu, fuellt die Luecken.
MARKER_FILE = Path("/shared/config/.chat_backup_projectid_backfill_v2")
# _build_core_text (Bridge) PREPENDT bei User-Nachrichten Hinweis-/GPS-Bloecke
# in eckigen Klammern vor den eigentlichen Text; conversation.jsonl speichert
# diesen angereicherten Text, chat_backup nur den rohen. FILE-Marker stehen in
# conversation-Assistant-Turns, sind in chat_backup aber schon rausgecleant.
_FILE_MARKER_RE = re.compile(r"\[FILE:\s*/shared/uploads/[^\]]+\]", re.IGNORECASE)
_LEADING_BRACKET_RE = re.compile(r"^\s*(?:\[[^\]]*\]\s*)+")
_WS_RE = re.compile(r"\s+")
def _norm(text: str) -> str:
"""Match-Key: FILE-Marker + fuehrende [Hinweis]/[GPS]-Bloecke entfernen,
Whitespace kollabieren, auf 120-Zeichen-Praefix kuerzen. Toleriert damit
die Anreicherungs-/Cleaning-Unterschiede zwischen conversation und backup,
bleibt durch den 120er-Praefix aber spezifisch genug gegen Fehl-Matches."""
t = _FILE_MARKER_RE.sub("", text or "")
t = _LEADING_BRACKET_RE.sub("", t)
t = _WS_RE.sub(" ", t).strip()
return t[:120]
def run() -> dict:
"""Fuehrt die Migration aus. Returns Status-Dict fuers Logging.
Laeuft nur einmal (Marker). Fehlt eine der Quelldateien: still ueberspringen."""
if MARKER_FILE.exists():
return {"skipped": "marker_exists"}
if not CHAT_BACKUP_FILE.exists():
return {"skipped": "no_chat_backup"}
if not CONVERSATION_FILE.exists():
# Ohne Brain-Historie gibt es nichts zu uebernehmen — Marker trotzdem
# setzen, damit wir nicht bei jedem Start neu pruefen.
_write_marker(0, 0)
return {"skipped": "no_conversation"}
# 1) conversation.jsonl → Deque der project_ids je (role, normtext), in Reihenfolge.
tag_queues: dict[tuple[str, str], deque[str]] = defaultdict(deque)
conv_turns = 0
for line in _iter_jsonl(CONVERSATION_FILE):
role = line.get("role")
if role not in ("user", "assistant"):
continue
content = line.get("content")
if not isinstance(content, str):
continue
conv_turns += 1
tag_queues[(role, _norm(content))].append((line.get("project_id") or "").strip())
# 2) chat_backup.jsonl durchgehen, leere project_ids nachtragen.
try:
backup_lines = CHAT_BACKUP_FILE.read_text(encoding="utf-8").splitlines()
except Exception as exc:
logger.warning("[backfill] chat_backup lesen fehlgeschlagen: %s", exc)
return {"error": f"read_backup: {exc}"}
out_lines: list[str] = []
patched = 0
matched = 0
for raw in backup_lines:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
out_lines.append(raw) # unveraendert durchreichen
continue
role = obj.get("role")
text = obj.get("text")
# Nur echte Chat-Bubbles matchen (keine file_deleted-/type-Marker).
if role in ("user", "assistant") and isinstance(text, str):
q = tag_queues.get((role, _norm(text)))
if q:
pid = q.popleft() # verbraucht → Reihenfolge fuer Duplikate bleibt korrekt
matched += 1
existing = (obj.get("project_id") or "").strip()
# Nur setzen wenn Backup-Eintrag noch KEINEN Tag hat und der
# conversation-Turn einem Projekt gehoert. Bestehende Tags bleiben.
if not existing and pid:
obj["project_id"] = pid
patched += 1
out_lines.append(json.dumps(obj, ensure_ascii=False))
# 3) Nichts zu tun? Marker setzen und raus.
if patched == 0:
_write_marker(conv_turns, 0)
logger.info("[backfill] nichts nachzutragen (conv_turns=%s, matched=%s)",
conv_turns, matched)
return {"conv_turns": conv_turns, "matched": matched, "patched": 0}
# 4) Sicherung + atomarer Rewrite.
try:
bak = CHAT_BACKUP_FILE.with_suffix(".jsonl.pre-backfill-v2.bak")
if not bak.exists():
bak.write_bytes(CHAT_BACKUP_FILE.read_bytes())
tmp = CHAT_BACKUP_FILE.with_suffix(".jsonl.tmp")
tmp.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
os.replace(tmp, CHAT_BACKUP_FILE)
except Exception as exc:
logger.warning("[backfill] Rewrite fehlgeschlagen: %s", exc)
return {"error": f"rewrite: {exc}"}
_write_marker(conv_turns, patched)
logger.info("[backfill] %s Bubbles nachtraeglich getaggt (conv_turns=%s, matched=%s). Backup: %s",
patched, conv_turns, matched, bak.name)
return {"conv_turns": conv_turns, "matched": matched, "patched": patched}
def _iter_jsonl(path: Path):
try:
for raw in path.read_text(encoding="utf-8").splitlines():
raw = raw.strip()
if not raw:
continue
try:
yield json.loads(raw)
except Exception:
continue
except Exception as exc:
logger.warning("[backfill] %s lesen fehlgeschlagen: %s", path, exc)
def _write_marker(conv_turns: int, patched: int) -> None:
try:
MARKER_FILE.parent.mkdir(parents=True, exist_ok=True)
MARKER_FILE.write_text(
json.dumps({"conv_turns": conv_turns, "patched": patched}, ensure_ascii=False),
encoding="utf-8",
)
except Exception as exc:
logger.warning("[backfill] Marker schreiben fehlgeschlagen: %s", exc)
+84 -8
View File
@@ -618,6 +618,11 @@ class ARIABridge:
# interceptiert und aendern hier den Sticky OHNE Brain-Roundtrip. # interceptiert und aendern hier den Sticky OHNE Brain-Roundtrip.
self._voice_sticky_project_id: str = "" self._voice_sticky_project_id: str = ""
self._voice_sticky_expires_at: float = 0.0 self._voice_sticky_expires_at: float = 0.0
# Focused-project pro Stream: die App schickt bei stt_stream_start
# die projectId ihres aktuellen Focus mit. Wenn das Voice-Ergebnis
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
self._stt_stream_projects: dict[str, str] = {}
# Voice-Override aus letzter Chat-Nachricht einer App. # Voice-Override aus letzter Chat-Nachricht einer App.
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt. # Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request). # So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
@@ -1223,6 +1228,10 @@ class ARIABridge:
server_path = f.get("serverPath") server_path = f.get("serverPath")
if turn_pid and server_path: if turn_pid and server_path:
self._tag_file_to_project(server_path, turn_pid) self._tag_file_to_project(server_path, turn_pid)
# projectId mitschicken, damit App+Diagnostic die Datei-Bubble dem
# richtigen Kontext zuordnen (sonst faellt sie im Diagnostic-Focus-
# Filter durch = wird nur im Hauptchat angezeigt).
f["projectId"] = turn_pid
await self._broadcast_aria_file(f) await self._broadcast_aria_file(f)
# Bei fehlenden Files: User informieren (sonst sieht er nur stille # Bei fehlenden Files: User informieren (sonst sieht er nur stille
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht # Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
@@ -2788,6 +2797,41 @@ class ARIABridge:
future.set_result(text) future.set_result(text)
return return
elif msg_type == "stt_stream_start":
# App startet eine neue Streaming-STT-Session. Wir merken uns
# ihre Focus-projectId damit der Voice-Router beim spaeteren
# stt_endpoint einen sinnvollen Default hat (Voice folgt dem
# visuellen Focus).
req_id = payload.get("requestId", "") or ""
focused_pid = str(payload.get("projectId") or "")
if req_id:
self._stt_stream_projects[req_id] = focused_pid
logger.info("[rvs] stt_stream_start id=%s focus=%s",
req_id[:12], focused_pid or "(main)")
return
elif msg_type == "stt_stream_end":
# Session vorbei — ABER nicht sofort aufraeumen: nach einem manuellen
# Stop folgt noch der finale stt_endpoint (Whisper-Final-Transcribe),
# der die Focus-projectId aus dieser Registry braucht. Wuerden wir hier
# sofort poppen, bekaeme der Endpoint "" → die Nachricht (und ARIA's
# Antwort) landet im Hauptchat statt im fokussierten Projekt. Der
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
req_id = payload.get("requestId", "") or ""
if req_id:
try:
loop = asyncio.get_event_loop()
loop.call_later(
20.0,
lambda rid=req_id: self._stt_stream_projects.pop(rid, None),
)
except Exception:
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
# dann lieber gar nicht aufraeumen als crashen.
pass
return
elif msg_type == "stt_endpoint": elif msg_type == "stt_endpoint":
# Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus # Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus
# einen Endpoint erkannt und schickt den finalen Text direkt. # einen Endpoint erkannt und schickt den finalen Text direkt.
@@ -2836,9 +2880,15 @@ class ARIABridge:
if self._is_duplicate_client_msg(client_msg_id): if self._is_duplicate_client_msg(client_msg_id):
return return
# App-Focus aus stt_stream_start-Registry auflösen (falls die
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
stream_req_id = payload.get("requestId", "") or ""
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
asyncio.create_task(self._process_endpoint_text( asyncio.create_task(self._process_endpoint_text(
text, interrupted, audio_request_id, location, text, interrupted, audio_request_id, location,
client_msg_id=client_msg_id)) client_msg_id=client_msg_id,
focused_project_id=focused_pid))
return return
elif msg_type == "oauth_callback": elif msg_type == "oauth_callback":
@@ -2986,7 +3036,20 @@ class ARIABridge:
# Voice-Router-Konstanten # Voice-Router-Konstanten
_VOICE_STICKY_TIMEOUT_SEC = 30.0 _VOICE_STICKY_TIMEOUT_SEC = 30.0
_VOICE_META_BACK_TO_MAIN = re.compile( _VOICE_META_BACK_TO_MAIN = re.compile(
r"^\s*(?:aria[,.]?\s+)?(?:zur(?:ü|ue)ck\s+zum\s+hauptchat|hauptchat\s+bitte|aria\s+hauptchat)\s*[.!?]?\s*$", r"^\s*(?:aria[,.]?\s+)?"
r"(?:"
# „zurück zum hauptchat / hauptmenü / haupt / menü / main"
r"zur(?:ü|ue)ck\s+(?:zum|zur|ins?|in\s+den)\s+"
r"(?:hauptchat|hauptmen(?:ü|ue)|haupt|men(?:ü|ue)|main)"
r"|"
# „zurück hauptchat / zurück haupt"
r"zur(?:ü|ue)ck\s+(?:hauptchat|hauptmen(?:ü|ue)|haupt|main)"
r"|"
# „hauptchat bitte", „aria hauptchat" (auch mit Menü/Main)
r"(?:hauptchat|hauptmen(?:ü|ue)|main)\s+bitte"
r"|"
r"aria[,.]?\s+(?:hauptchat|hauptmen(?:ü|ue)|haupt|main)"
r")\s*[.!?]?\s*$",
re.IGNORECASE, re.IGNORECASE,
) )
_VOICE_META_PROJECT_PREFIX = re.compile( _VOICE_META_PROJECT_PREFIX = re.compile(
@@ -2994,7 +3057,8 @@ class ARIABridge:
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
def _apply_voice_router(self, text: str) -> tuple[bool, str, str, str]: def _apply_voice_router(self, text: str,
default_project_id: str = "") -> tuple[bool, str, str, str]:
"""Voice-Router: entscheidet ob ein STT-Text ans Brain geht und wenn ja """Voice-Router: entscheidet ob ein STT-Text ans Brain geht und wenn ja
an welchen Projekt-Kontext. an welchen Projekt-Kontext.
@@ -3004,9 +3068,14 @@ class ARIABridge:
- should_forward=True: cleaned_text ans Brain, project_id ist Focus. - should_forward=True: cleaned_text ans Brain, project_id ist Focus.
Bei Prefix wird der Prefix aus dem Text entfernt. Bei Prefix wird der Prefix aus dem Text entfernt.
Sticky-Logik: nach einem projekt-getaggten Voice-Turn wird der Sticky Prioritaets-Reihenfolge:
30s lang gehalten. Innerhalb dieses Fensters gehen weitere Voice-Msgs 1. Meta „zurueck zum hauptchat" → Sticky reset, kein Forward.
OHNE Prefix in dasselbe Projekt. Nach Ablauf: Default Hauptchat. 2. „fuer <name>:"-Prefix → Sticky auf gematchtes Projekt.
3. Sticky aktiv (<=30s alt) → dessen Projekt.
4. default_project_id (App-Focus) — Voice folgt dem sichtbaren
Kontext. Wenn App in Projekt X guckt, geht die STT-Nachricht
ohne weitere Marker dort rein.
5. Fallback: Hauptchat.
""" """
import time as _time import time as _time
now = _time.time() now = _time.time()
@@ -3056,13 +3125,18 @@ class ARIABridge:
return (True, stripped, self._voice_sticky_project_id, "sticky") return (True, stripped, self._voice_sticky_project_id, "sticky")
# Sticky abgelaufen — zurücksetzen # Sticky abgelaufen — zurücksetzen
self._voice_sticky_project_id = "" self._voice_sticky_project_id = ""
# 4) App-Focus als Default: Voice folgt dem sichtbaren Kontext
if default_project_id:
return (True, stripped, default_project_id, "app_focus")
# 5) Fallback Hauptchat
return (True, stripped, "", "default") return (True, stripped, "", "default")
async def _process_endpoint_text(self, text: str, async def _process_endpoint_text(self, text: str,
interrupted: bool = False, interrupted: bool = False,
audio_request_id: str = "", audio_request_id: str = "",
location: Optional[dict] = None, location: Optional[dict] = None,
client_msg_id: Optional[str] = None) -> None: client_msg_id: Optional[str] = None,
focused_project_id: str = "") -> None:
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text """Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain. schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
@@ -3075,7 +3149,9 @@ class ARIABridge:
selbst geht NICHT ans Brain, sondern broadcastet als project_changed- selbst geht NICHT ans Brain, sondern broadcastet als project_changed-
Event → App+Diagnostic wechseln den Focus. Event → App+Diagnostic wechseln den Focus.
""" """
should_forward, cleaned, project_id, meta_action = self._apply_voice_router(text) should_forward, cleaned, project_id, meta_action = self._apply_voice_router(
text, default_project_id=focused_project_id,
)
if meta_action in ("back_to_main", "project_prefix"): if meta_action in ("back_to_main", "project_prefix"):
# UI-Focus-Update broadcasten # UI-Focus-Update broadcasten
+23 -2
View File
@@ -1751,7 +1751,11 @@
} }
if (msg.type === 'chat_final') { if (msg.type === 'chat_final') {
addChat('received', msg.text || '', 'chat:final'); // KEINE Bubble mehr rendern: ARIA-Antworten kommen ausschliesslich via
// rvs_chat (traegt projectId → landet im richtigen Kontext). chat_final
// stammt vom Gateway-Watch und hat KEINE projectId — wuerde also eine
// untagged Duplikat-Bubble im Hauptchat erzeugen. Nur noch als
// Aktivitaets-/Trace-Ende-Signal relevant (das macht der Server).
return; return;
} }
if (msg.type === 'file_from_aria') { if (msg.type === 'file_from_aria') {
@@ -1906,7 +1910,7 @@
const m = msg.messages[mi]; const m = msg.messages[mi];
try { try {
if (m.type === 'aria_file') { if (m.type === 'aria_file') {
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted }); addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted, projectId: m.projectId });
continue; continue;
} }
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim(); const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
@@ -1924,6 +1928,11 @@
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `chat-msg ${m.type}`; el.className = `chat-msg ${m.type}`;
if (m.ts) el.dataset.ts = String(m.ts); if (m.ts) el.dataset.ts = String(m.ts);
// Multi-Threading: Kontext-Zuordnung fuer den Focus-Filter.
// Ohne das landete beim Reload JEDE Bubble im Hauptchat
// (dataset.projectId undefined → '' → nur bei Hauptchat-Focus
// sichtbar), Projekte blieben leer.
el.dataset.projectId = m.projectId || '';
el.innerHTML = innerHtml; el.innerHTML = innerHtml;
b.appendChild(el); b.appendChild(el);
} }
@@ -1945,6 +1954,9 @@
} }
for (const b of boxes) b.scrollTop = b.scrollHeight; for (const b of boxes) b.scrollTop = b.scrollHeight;
} }
// Nach dem Neuaufbau den aktuellen Kontext-Focus anwenden: Bubbles
// die nicht zum fokussierten Projekt gehoeren ausblenden.
updateChatVisibilityByFocus();
if (errorCount > 0) { if (errorCount > 0) {
console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`); console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`);
} }
@@ -2331,6 +2343,15 @@
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'chat-msg received'; el.className = 'chat-msg received';
el.dataset.ariaFilePath = serverPath; el.dataset.ariaFilePath = serverPath;
// Kontext-Zuordnung fuer den Focus-Filter — ohne das wurde die
// Datei-Bubble beim Reload ausgeblendet wenn ein Projekt fokussiert war.
const filePid = p.projectId || '';
el.dataset.projectId = filePid;
// Beim Live-Anhaengen den aktuellen Focus respektieren (wie addChat),
// sonst blitzt eine Projekt-Datei kurz im Hauptchat auf.
if (typeof focusedContextId === 'string' && filePid !== focusedContextId) {
el.style.display = 'none';
}
if (deleted) el.dataset.deleted = '1'; if (deleted) el.dataset.deleted = '1';
el.innerHTML = html; el.innerHTML = html;
box.appendChild(el); box.appendChild(el);
+9 -7
View File
@@ -660,11 +660,11 @@ function handleGatewayMessage(msg) {
broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "agent_activity", activity: "idle" });
pendingMessageTime = 0; // Watchdog: Antwort erhalten pendingMessageTime = 0; // Watchdog: Antwort erhalten
updateAgentActivity(); updateAgentActivity();
// Antwort in Backup-Log schreiben // KEIN chat_backup-Write mehr hier: die Bridge (_process_core_response)
try { // ist der massgebliche Writer und schreibt den Assistant-Eintrag MIT
const entry = JSON.stringify({ ts: Date.now(), role: "assistant", text: text.slice(0, 2000), session: activeSessionKey }) + "\n"; // project_id. Dieser Gateway-Watch-Pfad kennt die project_id nicht —
fs.appendFileSync("/shared/config/chat_backup.jsonl", entry); // ein Write hier erzeugte ein untagged Duplikat, das beim Reload im
} catch {} // Hauptchat auftaucht (statt im Projekt).
return; return;
} }
@@ -2746,8 +2746,9 @@ async function handleLoadChatHistory(clientWs) {
if (obj.role !== "user" && obj.role !== "assistant") continue; if (obj.role !== "user" && obj.role !== "assistant") continue;
const ts = obj.ts || 0; const ts = obj.ts || 0;
const text = String(obj.text || ""); const text = String(obj.text || "");
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
if (obj.role === "user") { if (obj.role === "user") {
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts }); if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
continue; continue;
} }
// assistant: nach FILE-Markern scannen, eigene aria_file-Eintraege pro Datei // assistant: nach FILE-Markern scannen, eigene aria_file-Eintraege pro Datei
@@ -2769,9 +2770,10 @@ async function handleLoadChatHistory(clientWs) {
size, size,
ts, ts,
deleted: wasDeleted || !exists, deleted: wasDeleted || !exists,
projectId,
}); });
} }
if (text) messages.push({ type: "received", text, meta: "chat:final", ts }); if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId });
} }
clientWs.send(JSON.stringify({ type: "chat_history", messages })); clientWs.send(JSON.stringify({ type: "chat_history", messages }));
+61 -12
View File
@@ -69,6 +69,18 @@ STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was
STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
# Akustisches Endpointing (ergaenzt die rein-semantische Stagnation).
# Motivation: der reine „Transkript waechst nicht mehr"-Endpoint feuert zu
# frueh (kurze Sprech-Pausen, beam_size=1-Instabilitaet) oder gar nicht
# (Whisper oszilliert/halluziniert). Echte akustische Stille ist das robuste
# „User hat aufgehoert"-Signal.
STREAM_ENERGY_WINDOW_MS = 300 # RMS ueber die letzten 300ms Audio messen
STREAM_VOICE_RMS_THRESHOLD = 0.012 # RMS darueber = Sprache (haelt Session am Leben)
# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung,
# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x
# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird.
STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
class WhisperRunner: class WhisperRunner:
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded().""" """Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
@@ -310,6 +322,7 @@ class StreamSession:
last_partial: str = "" last_partial: str = ""
last_growth_at: float = 0.0 last_growth_at: float = 0.0
last_transcribe_at: float = 0.0 last_transcribe_at: float = 0.0
last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie
closed: bool = False # nach stream_end gesetzt closed: bool = False # nach stream_end gesetzt
endpoint_sent: bool = False # Endpoint nur einmal feuern endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten # Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
@@ -533,6 +546,35 @@ class SessionManager:
if audio_ms < STREAM_MIN_AUDIO_MS: if audio_ms < STREAM_MIN_AUDIO_MS:
return return
# Akustische Sprach-Aktivitaet JEDEN Tick (~200ms) messen — unabhaengig
# vom Transcribe-Throttle. Solange wirklich gesprochen wird, bleibt die
# Session am Leben, auch wenn Whisper gerade keinen neuen Text liefert.
if self._tail_rms(sess) >= STREAM_VOICE_RMS_THRESHOLD:
sess.last_voice_at = now
# Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde:
# (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist
# fertig. Das ist der robuste Primaerpfad gegen „hoert nach zwei
# Worten auf" (waehrend echten Sprechens ist Energie da → kein Cut).
# (b) semantisch (Backstop): Transkript stagniert deutlich laenger als
# endpoint_ms — fuer laute Umgebungen wo die Energie nie faellt.
if sess.last_growth_at > 0.0 and not sess.endpoint_sent:
acoustic_silence_ms = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0
semantic_silence_ms = (now - sess.last_growth_at) * 1000.0
acoustic_done = sess.last_voice_at > 0 and acoustic_silence_ms >= sess.endpoint_ms
semantic_done = semantic_silence_ms >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR
if acoustic_done or semantic_done:
logger.info(
"Stream %s: Endpoint (%s) — akustisch %dms / semantisch %dms — Text=%r",
sess.request_id[:8],
"akustisch" if acoustic_done else "semantisch",
int(acoustic_silence_ms), int(semantic_silence_ms),
sess.last_partial[:80],
)
await self._finalize(sess, ws,
reason="endpoint" if acoustic_done else "endpoint_semantic")
return
# Transcribe-Throttling # Transcribe-Throttling
since_last = (now - sess.last_transcribe_at) * 1000.0 since_last = (now - sess.last_transcribe_at) * 1000.0
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS: if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
@@ -568,18 +610,8 @@ class SessionManager:
}) })
await _debug_log(ws, "stream.partial", await _debug_log(ws, "stream.partial",
f"id={sess.request_id[:12]} text={text[:80]!r}") f"id={sess.request_id[:12]} text={text[:80]!r}")
else: # else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
# Stagnation pruefen — Endpoint-Bedingung # semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun.
if sess.last_growth_at == 0.0:
# Noch gar kein Text erkannt. Wenn der User gar nichts sagt
# springt Brain irgendwann aus eigenem Conversation-Window-
# Timeout in der App raus; wir machen hier nix.
return
silence_ms = (now - sess.last_growth_at) * 1000.0
if silence_ms >= sess.endpoint_ms and not sess.endpoint_sent:
logger.info("Stream %s: Endpoint nach %dms ohne neuen Text — Text=%r",
sess.request_id[:8], int(silence_ms), sess.last_partial[:80])
await self._finalize(sess, ws, reason="endpoint")
def _buffer_duration_ms(self, sess: StreamSession) -> float: def _buffer_duration_ms(self, sess: StreamSession) -> float:
# 16-bit s16le mono → 2 bytes pro Sample # 16-bit s16le mono → 2 bytes pro Sample
@@ -588,6 +620,23 @@ class SessionManager:
return 0.0 return 0.0
return (samples / sess.sample_rate) * 1000.0 return (samples / sess.sample_rate) * 1000.0
def _tail_rms(self, sess: StreamSession) -> float:
"""RMS-Energie der letzten STREAM_ENERGY_WINDOW_MS des Audio-Buffers.
Dient als akustisches redet noch / ist still"-Signal."""
win_bytes = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2
if win_bytes <= 0:
return 0.0
tail = sess.pcm_buffer[-win_bytes:]
if len(tail) < 2:
return 0.0
try:
arr = pcm_s16le_to_float32(bytes(tail))
except Exception:
return 0.0
if arr.size == 0:
return 0.0
return float(np.sqrt(np.mean(arr * arr)))
async def _finalize(self, sess: StreamSession, ws, reason: str) -> None: async def _finalize(self, sess: StreamSession, ws, reason: str) -> None:
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5), """Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
feuert stt_endpoint + stt_stream_done, droppt Session.""" feuert stt_endpoint + stt_stream_done, droppt Session."""