Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b27fba316b | |||
| 648698d202 | |||
| 3e88eecd9c | |||
| e33d1c782f | |||
| 1568c25ac4 | |||
| 97ee455ab4 | |||
| cd72068e76 | |||
| 8b567e15bf | |||
| 64c06db308 | |||
| 63dde6506f | |||
| 5fb08b4ea5 | |||
| d49ec64e27 |
@@ -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 20001
|
||||||
versionName "0.1.9.8"
|
versionName "0.2.0.1"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.9.8",
|
"version": "0.2.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -1414,6 +1420,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 +1516,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 +1573,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 +1889,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 +1972,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 +2594,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 +2607,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 +2632,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user