Compare commits

..

17 Commits

Author SHA1 Message Date
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
duffyduck 882f3def99 release: bump version to 0.1.9.8 2026-07-03 01:31:58 +02:00
duffyduck 092f085254 feat(bridge): Voice-Router — 30s-Sticky + Meta-Command-Interception
Phase 4 vom Multi-Threading-Redesign — der Voice-Layer routet STT-Text
per-Projekt und lässt Meta-Kommandos gar nicht erst ans Brain.

Voice-Router in _process_endpoint_text():
- „zurueck zum hauptchat" / „hauptchat bitte" / „aria hauptchat"
  → Sticky reset, project_changed(exited) broadcasten, KEIN Brain-Call.
- „fuer <name>: <text>" (Fuzzy-Match auf Projekt-Namen ≥ 0.6 Score)
  → Sticky auf gefundene project_id + Rest des Texts geht ans Brain
  im Projekt-Kontext. project_changed(entered) broadcasten damit
  App/Diagnostic den Focus mit umschalten.
- Sticky-Timeout 30s: eine Voice-Message ohne Prefix innerhalb des
  Fensters bleibt im Sticky-Projekt, refresht das Timeout. Nach Ablauf
  → Default Hauptchat.
- Meta-Kommandos aendern KEINEN Brain-State — ARIAs Arbeit in laufenden
  Projekten wird nicht abgebrochen.

send_to_core wird jetzt mit dem gerouteten project_id gerufen; das Brain
bekommt den Text im richtigen Queue-Kontext.

Broadcast-Chain: Voice-Router setzt Sticky → project_changed geht via RVS
an App+Diagnostic → Focus-Header/Kontext-Strip wechseln automatisch.

Damit ist der komplette Multi-Threading-Redesign abgeschlossen:
- Brain: per-Request project_id + per-Projekt Queue + Queue-Aware Prompt
- Bridge: Chat-Routing + Voice-Router
- App: Focus-One + Drawer + Status-Dots
- Diagnostic: Kontext-Strip + Focus-Filter
- Voice: Sticky + Meta-Interception

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:59:02 +02:00
duffyduck 21eac63723 feat(diagnostic): Multi-Threading UI — Kontext-Strip + Focus-Filter + Queue-Polling
Phase 3 vom Multi-Threading-Redesign. Diagnostic zeigt einen scrollbaren
Streifen von Kontext-Karten ueber dem Chat (Hauptchat + Projekte), jede
mit Live-Status-Dot. Tap wechselt den Focus, Chat filtert auf diesen
Kontext, Sende-Input laeuft mit der Focus-ID durch Bridge → Brain-Queue.

index.html:
- Neuer <div id="chat-context-strip"> ueber der Chat-Box, horizontal
  scrollbar.
- JS: focusedContextId (in localStorage gespiegelt), diagQueueStatus,
  diagProjectsCache. renderContextStrip() zeichnet Karten mit Dot
  + Status-Label. switchDiagFocus(id) wechselt Focus + versteckt
  Bubbles anderer Kontexte via data-project-id + style.display.
- Polling: /api/brain/projects/queue-status alle 2s, /projects/list
  alle 15s.
- addChat: nimmt options.projectId → schreibt data-project-id an die
  DOM-Node, versteckt sofort wenn Focus abweicht.
- Chat-Reception-Handler propagiert p.projectId aus dem RVS-Payload.
- testRVS() sendet msg.projectId=focusedContextId mit.

server.js:
- sendToRVS(text, isTrace, projectId): neuer Param, wird in
  payload.projectId gesetzt → Bridge routet an /chat body.project_id.
- test_rvs-Handler reicht msg.projectId durch.

Bewusst nicht drin (Follow-up wenn Stefan mag):
- Voller Dashboard-Stack mit stacked Karten die eigene Message-Listen +
  Input-Felder haben. Aktuelle Variante ist „Kontext-Strip fuer schnellen
  Wechsel + Focus-One-Rendering" — ~90% des UX-Werts mit ~10% des Aufwands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:56:30 +02:00
duffyduck 06316da36f feat(app): Multi-Threading UI — Focus-One-View + Drawer + Queue-Status-Dots
Phase 2 vom Multi-Threading-Redesign. Chat zeigt jetzt genau EINEN Kontext
(Hauptchat oder Projekt X) — die anderen laufen im Brain weiter, sichtbar
nur ueber Status-Dots im Drawer.

ChatScreen:
- Reorder-Trick + collapsible Project-Bloecke raus. messagesForRender filtert
  jetzt direkt auf focusedProjectId.
- Neuer Focus-Header oben: ☰ Drawer-Toggle + Kontext-Name + Status-Dot
  (gruen idle / gelb queue / rot arbeitet). Drawer-Icon kriegt ein Badge
  mit der Anzahl OTHERE aktiver Kontexte.
- Focus in AsyncStorage gespiegelt — Neustart restauriert den letzten Blick.
- brainApi.getProjectQueueStatus() alle 2s gepollt fuer Status-Dots.
- project_changed-Event steuert Focus-Wechsel (App-lokal, kein Brain-Roundtrip).

brainApi:
- Neuer Typ QueueContextStatus + ProjectQueueStatus.
- Methode getProjectQueueStatus() → /projects/queue-status.

ProjectsBrowser:
- Nimmt queueStatus als Prop, rendert Status-Dot pro Zeile (Hauptchat +
  Projekte).
- switchTo ruft NICHT mehr brainApi.switchProject (kein globaler active
  mehr) — direkt onActiveChanged mit dem Projekt-Objekt aus der Liste,
  schliesst danach die Modal.
- Label ✓ FOCUS statt ✓ AKTIV — praeziser fuer's neue Modell.

SettingsScreen:
- File-Manager-Filter-Default nutzt AsyncStorage statt Brain-Query.

Bewusst nicht drin (Follow-up):
- OS-Push wenn Projekt fertig ist — braucht Firebase-Setup, kommt separat
  wenn die visuellen Dots nicht reichen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 20:52:14 +02:00
duffyduck 7927ad05ae feat(brain): Multi-Threading via per-request project_id + per-project queue
Erster Schritt zum echten Multi-Threading fuer ARIA-Projekte. Kein globaler
active_project-State mehr — jeder /chat-Request sagt selbst welche Buehne
(project_id im Body). Verschiedene Projekte laufen parallel, gleiches
Projekt queued via asyncio.Lock.

Backend:
- ChatIn.project_id: Client bestimmt pro Request wohin. Bridge routet.
- /chat: async, holt per-Projekt asyncio.Lock. Requests fuers gleiche
  Projekt reihen sich in _project_pending ein, warten am Lock. Requests
  fuer verschiedene Projekte laufen echt parallel.
- Neuer /projects/queue-status endpoint: pro Kontext (inkl. Hauptchat
  unter __main__): busy True/False + queue_size. Fuers UI-Status-Dots.
- Agent.chat() nimmt project_id + pending_queue Params. Kein
  projects_mod.get_active() mehr im Hot-Path.

Queue-Aware Prompting:
- Wenn nach dem aktuellen Turn weitere Nachrichten in der Queue liegen,
  wird der System-Prompt um ein QUEUE-Segment erweitert mit Instruktion:
  „Bevor Du den aktuellen Task loesst, pruef die Queue — widerspricht/
  annuliert eine spaetere Nachricht? Dann Skip-Antwort statt Doppelarbeit."
- Beispiel: Task 'titelleiste rot' + Queue-Tail 'doch nicht, blau'
  → ARIA skipt rot, blau kommt als naechste Anfrage sauber durch.
- Kein extra LLM-Call — reine Prompt-Injection.

Project-Tools:
- project_enter/exit sind jetzt UI-Signale (App wechselt Ansicht via
  project_changed event), aendern KEINEN Brain-State mehr. Der aktuelle
  Turn bleibt in seinem Chat-Kontext.
- project_list zeigt keinen "AKTIV"-Marker mehr (nicht mehr sinnvoll).
- projects_mod.set_active/get_active bleiben als Legacy-Helpers (kein
  Aufruf mehr aus dem Hot-Path).

Bridge:
- send_to_core packt project_id in den /chat-Body.
- User-Backup-Eintrag tag't project_id sauber, keine Brain-Query mehr.

Naechste Schritte (kommende Commits):
- App: Focus-One-View mit Drawer + Status-Dots + OS-Push
- Diagnostic: Dashboard-Stack mit Karten
- Voice-Router: 30s-Sticky + Meta-Command-Interception im wakeword.ts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 17:57:30 +02:00
14 changed files with 1020 additions and 325 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10907
versionName "0.1.9.7"
versionCode 20001
versionName "0.2.0.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.9.7",
"version": "0.2.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+54 -15
View File
@@ -33,9 +33,18 @@ interface Props {
/** Optional — wenn als Modal genutzt, sonst inline */
visible?: boolean;
onClose?: () => void;
/** Wird gerufen wenn sich das aktive Projekt aendert — ChatScreen
* refresht dann seinen Banner-State. */
/** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale
* UI-Entscheidung, wechselt den Chat-Focus). */
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).
* Wenn geliefert: Status-Dot pro Zeile gerendert. */
queueStatus?: Record<string, { busy: boolean; queue_size: number }>;
}
function _fmtRel(unixSec: number): string {
@@ -48,7 +57,14 @@ function _fmtRel(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleDateString('de-DE');
}
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged }) => {
export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onActiveChanged, currentFocusId, queueStatus }) => {
const _statusDot = (pid: string) => {
const s = queueStatus?.[pid];
if (!s) return { color: '#555570', label: '' };
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
return { color: '#34C759', label: 'idle' };
};
const [projects, setProjects] = useState<Project[]>([]);
const [activeId, setActiveId] = useState<string>('');
const [loading, setLoading] = useState(false);
@@ -70,9 +86,11 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
setLoading(true); setErr(null);
brainApi.getProjectStatus()
.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 || []);
setActiveId(status.active_id || '');
onActiveChangedRef.current?.(status.active);
})
.catch(e => setErr(String(e?.message || e)))
.finally(() => setLoading(false));
@@ -80,6 +98,10 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
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
useEffect(() => {
if (!visible) return;
@@ -88,13 +110,14 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
}, [visible, load]);
const switchTo = useCallback((id: string) => {
brainApi.switchProject(id)
.then(status => {
setActiveId(status.active_id || '');
onActiveChangedRef.current?.(status.active);
})
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, []);
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
// Wir suchen das Projekt lokal aus der Liste, damit die App den Namen kennt.
setActiveId(id);
const p = id ? (projects.find(x => x.id === id) || null) : null;
onActiveChangedRef.current?.(p);
if (onClose) onClose();
}, [projects, onClose]);
const createProject = useCallback(() => {
const name = newName.trim();
@@ -152,6 +175,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId;
const dot = _statusDot(item.id);
return (
<TouchableOpacity
onPress={() => switchTo(item.id)}
@@ -160,15 +184,19 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{queueStatus && (
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> AKTIV</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
{item.description ? (
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
) : null}
<Text style={s.rowMeta}>
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</View>
</TouchableOpacity>
@@ -191,18 +219,29 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
</View>
{/* Hauptchat-Eintrag (immer oben) */}
{(() => {
const dot = _statusDot('__main__');
return (
<TouchableOpacity
onPress={() => switchTo('')}
style={[s.row, !activeId && s.rowActive]}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{queueStatus && (
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)}
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
{!activeId && <Text style={s.activeBadge}> AKTIV</Text>}
{!activeId && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
<Text style={s.rowMeta}>Standard-Verlauf, keine Projekt-Zuordnung</Text>
<Text style={s.rowMeta}>
Standard-Verlauf, keine Projekt-Zuordnung
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</View>
</TouchableOpacity>
);
})()}
{loading ? (
<View style={{ padding: 24, alignItems: 'center' }}>
+137 -189
View File
@@ -286,9 +286,15 @@ const ChatScreen: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [projectsVisible, setProjectsVisible] = useState(false);
const [activeProject, setActiveProject] = useState<BrainProject | null>(null);
// Lookup-Map id → Projekt — fuer Header-Names im Chat-Verlauf
// Focus-One-View: welchen Chat sieht Stefan gerade?
// Leer = Hauptchat, sonst die project_id. Multi-Threading:
// Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
// die laufen im Brain weiter, wir sehen sie hier nur nicht.
const [focusedProjectId, setFocusedProjectId] = useState<string>('');
// Lookup-Map id → Projekt (fuer Drawer + Referenzen)
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
// Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
const [queueStatus, setQueueStatus] = useState<Record<string, { busy: boolean; queue_size: number }>>({});
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
@@ -465,27 +471,59 @@ const ChatScreen: React.FC = () => {
}, [dispatchWithAck]);
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System)
// Aktives Projekt initial laden + bei RVS-Reconnect refreshen.
// Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage).
// Projekt-Namen laden (Lookup-Map) + focusedProjectId aus AsyncStorage
// wiederherstellen (Default = Hauptchat wenn nichts gespeichert). Der
// Brain hat mit Multi-Threading keinen global-aktiven Projekt-State mehr;
// Focus ist reine App-lokale UI-Info.
useEffect(() => {
const loadProject = () => {
brainApi.getProjectStatus()
.then(s => {
setActiveProject(s.active || null);
// Lookup-Map fuellen damit der Chat-Verlauf Header mit Namen rendern kann
const loadNames = () => {
brainApi.listProjects(true)
.then(list => {
const map: Record<string, string> = {};
for (const p of (s.projects || [])) map[p.id] = p.name;
if (s.active) map[s.active.id] = s.active.name;
for (const p of list) map[p.id] = p.name;
setProjectNameById(prev => ({ ...prev, ...map }));
})
.catch(() => {});
};
loadProject();
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); });
loadNames();
// Letzten Focus aus Storage restoren
AsyncStorage.getItem('aria_focused_project_id').then(v => {
if (v && typeof v === 'string') setFocusedProjectId(v);
}).catch(() => {});
const unsub = rvs.onStateChange(state => { if (state === 'connected') loadNames(); });
return () => unsub();
}, []);
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
// fuer den Auto-Fall angenehm.
useEffect(() => {
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
focusedProjectIdRef.current = 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
// fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout).
useEffect(() => {
let cancelled = false;
const poll = async () => {
if (rvs.getState() !== 'connected') return;
try {
const s = await brainApi.getProjectQueueStatus();
if (cancelled) return;
setQueueStatus(s.contexts || {});
} catch {}
};
poll();
const iv = setInterval(poll, 2000);
return () => { cancelled = true; clearInterval(iv); };
}, []);
useEffect(() => {
const loadSettings = async () => {
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
@@ -825,12 +863,20 @@ const ChatScreen: React.FC = () => {
return;
}
// project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt /
// betreten / verlassen / beendet. Banner refreshen.
// project_changed: ARIA hat via Tool ein Projekt erstellt/betreten/exited/beendet.
// App entscheidet ob sie den Focus wechselt basierend auf action + payload.
if (message.type === 'project_changed') {
brainApi.getProjectStatus()
.then(s => setActiveProject(s.active || null))
.catch(() => {});
const p: any = message.payload || {};
const action = p.action || '';
// Neuer Projekt-Name in Lookup-Map merken
if (p.id && p.name) {
setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
}
if (action === 'entered' || action === 'created') {
if (p.id) setFocusedProjectId(p.id);
} else if (action === 'exited') {
setFocusedProjectId('');
}
return;
}
@@ -1374,6 +1420,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
if (ok) {
@@ -1469,6 +1516,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
if (ok) {
ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
@@ -1525,6 +1573,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: 1500,
hardCapMs: Math.max(passiveMs + 5000, 35000),
projectId: focusedProjectIdRef.current,
});
if (!ok) {
console.warn('[Chat] passive streaming start failed — exit passive listening');
@@ -1629,116 +1678,14 @@ const ChatScreen: React.FC = () => {
[messages],
);
// Projekt-Bloecke: alle Nachrichten eines Projekts erscheinen als EIN Block
// — auch wenn der User zwischenzeitlich raus + wieder rein gegangen ist.
// Dafuer ordnen wir die Nachrichten so um, dass alle Messages eines Projekts
// contiguous werden, verankert am LETZTEN Aktivitaets-Timestamp des Projekts.
// Hauptchat-Nachrichten bleiben chronologisch dazwischen.
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set());
const reorderedMessages = useMemo(() => {
// Hauptchat-Nachrichten + Projekt-Gruppen trennen
const hauptchat: ChatMessage[] = [];
const groups = new Map<string, ChatMessage[]>();
for (const m of chatVisibleMessages) {
const pid = m.projectId || '';
if (!pid) hauptchat.push(m);
else {
const g = groups.get(pid);
if (g) g.push(m); else groups.set(pid, [m]);
}
}
// Events: jede Hauptchat-Bubble + jede Projekt-Gruppe als 1 Event
type Event =
| { ts: number; kind: 'msg'; m: ChatMessage }
| { ts: number; kind: 'group'; msgs: ChatMessage[] };
const events: Event[] = [];
for (const m of hauptchat) events.push({ ts: m.timestamp, kind: 'msg', m });
for (const [, msgs] of groups) {
const sorted = [...msgs].sort((a, b) => a.timestamp - b.timestamp);
const anchorTs = sorted[sorted.length - 1].timestamp;
events.push({ ts: anchorTs, kind: 'group', msgs: sorted });
}
events.sort((a, b) => a.ts - b.ts);
const out: ChatMessage[] = [];
for (const e of events) {
if (e.kind === 'msg') out.push(e.m);
else out.push(...e.msgs);
}
return out;
}, [chatVisibleMessages]);
const projectMeta = useMemo(() => {
// Pre-compute: welche Message ist Erst-Element ihrer Projekt-Gruppe?
// Plus: wieviele Messages pro Projekt insgesamt (fuer Header-Count).
const firstOfGroup = new Set<string>();
const counts = new Map<string, number>();
let lastPid: string | null = null;
for (const m of reorderedMessages) {
const pid = m.projectId || '';
if (pid) counts.set(pid, (counts.get(pid) || 0) + 1);
if (pid && pid !== lastPid) firstOfGroup.add(m.id);
lastPid = pid || null;
}
return { firstOfGroup, counts };
}, [reorderedMessages]);
// Render-Filter: bei collapsed Projekten zeigen wir NUR das erste Message-
// Item der Gruppe (das traegt den Header). Restliche Messages werden ausge-
// blendet — Header allein steht dann zwischen Hauptchat-Bubbles.
// Focus-One-View (Multi-Threading, 06/2026): Chat zeigt NUR die Nachrichten
// des gerade fokussierten Kontexts. Hauptchat (focusedProjectId leer) →
// alle ungeтагtgeд Nachrichten. Projekt X aktiv → nur Nachrichten mit
// projectId === X. ARIA arbeitet weiterhin in allen Kontexten parallel;
// wir sehen nur den einen.
const messagesForRender = useMemo(() => {
return reorderedMessages.filter(m => {
const pid = m.projectId || '';
if (!pid) return true;
if (!collapsedProjects.has(pid)) return true;
return projectMeta.firstOfGroup.has(m.id);
});
}, [reorderedMessages, collapsedProjects, projectMeta]);
// Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen.
const prevActiveIdRef = useRef<string>('');
useEffect(() => {
const newActive = activeProject?.id || '';
const prevActive = prevActiveIdRef.current;
if (newActive === prevActive) return;
setCollapsedProjects(prev => {
const next = new Set(prev);
if (prevActive) next.add(prevActive);
if (newActive) next.delete(newActive);
return next;
});
prevActiveIdRef.current = newActive;
}, [activeProject]);
// Default: alle Projekte einklappen außer dem aktiven (Stefan-Vision:
// „beim ersten Öffnen sind alle projekte eingeklappt").
const collapseInitialized = useRef(false);
useEffect(() => {
if (collapseInitialized.current) return;
if (chatVisibleMessages.length === 0) return;
const allPids = new Set<string>();
for (const m of chatVisibleMessages) {
if (m.projectId) allPids.add(m.projectId);
}
if (allPids.size === 0) return;
const activePid = activeProject?.id || '';
setCollapsedProjects(prev => {
const next = new Set(prev);
for (const pid of allPids) {
if (pid !== activePid) next.add(pid);
}
return next;
});
collapseInitialized.current = true;
}, [chatVisibleMessages, activeProject]);
const toggleProjectCollapse = useCallback((projectId: string) => {
setCollapsedProjects(prev => {
const next = new Set(prev);
if (next.has(projectId)) next.delete(projectId); else next.add(projectId);
return next;
});
}, []);
return chatVisibleMessages.filter(m => (m.projectId || '') === focusedProjectId);
}, [chatVisibleMessages, focusedProjectId]);
const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);
@@ -1942,7 +1889,7 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = activeProject?.id || '';
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
@@ -2025,6 +1972,7 @@ const ChatScreen: React.FC = () => {
noSpeechTimeoutMs: 0,
endpointMs: 1500,
hardCapMs: 300000,
projectId: focusedProjectIdRef.current,
});
if (!ok) {
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
@@ -2461,44 +2409,6 @@ const ChatScreen: React.FC = () => {
);
};
// Wrapper: setzt einen einklappbaren Projekt-Header VOR die Bubble wenn
// diese das erste Element ihrer Projekt-Gruppe ist. Bei collapsed
// → nur Header, keine Bubble. Hauptchat-Nachrichten gehen ohne Header durch.
const renderMessageWithProjectHeader = ({ item }: { item: ChatMessage }) => {
const pid = item.projectId || '';
if (!pid) return renderMessage({ item });
const isFirstOfGroup = projectMeta.firstOfGroup.has(item.id);
if (!isFirstOfGroup) return renderMessage({ item });
const isCollapsed = collapsedProjects.has(pid);
const projectName = projectNameById[pid] || pid;
const projectCount = projectMeta.counts.get(pid) || 0;
const header = (
<TouchableOpacity
onPress={() => toggleProjectCollapse(pid)}
style={{
marginVertical: 6, marginHorizontal: 8, paddingHorizontal: 10, paddingVertical: 8,
borderRadius: 6, backgroundColor: 'rgba(52,199,89,0.10)',
borderLeftWidth: 3, borderLeftColor: '#34C759',
flexDirection: 'row', alignItems: 'center', gap: 6,
}}
>
<Text style={{ fontSize: 14, color: '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
{isCollapsed ? '▶' : '▼'} 📁 {projectName}
</Text>
<Text style={{ fontSize: 11, color: '#8888AA' }}>
{projectCount} {projectCount === 1 ? 'Nachricht' : 'Nachrichten'}
</Text>
</TouchableOpacity>
);
if (isCollapsed) return header;
return (
<View>
{header}
{renderMessage({ item })}
</View>
);
};
// Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
// Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
// teilen kann ohne den umliegenden Text mitzunehmen.
@@ -2651,41 +2561,79 @@ const ChatScreen: React.FC = () => {
})()}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
{/* Focus-Indicator + Drawer-Toggle. Multi-Threading: das ist reine
Anzeige „was sehe ich gerade" — ARIA arbeitet gleichzeitig in
allen Kontexten weiter, wir zeigen hier nur einen. */}
{(() => {
const isMain = !focusedProjectId;
const focusedName = isMain ? '' : (projectNameById[focusedProjectId] || focusedProjectId);
const focusedQueue = queueStatus[isMain ? '__main__' : focusedProjectId];
const dot = focusedQueue?.busy
? { color: '#FF6E6E', label: 'arbeitet' }
: focusedQueue?.queue_size
? { color: '#FFD60A', label: `Queue: ${focusedQueue.queue_size}` }
: { color: '#34C759', label: 'idle' };
// Anzahl anderer Kontexte die gerade aktiv sind (fuer Drawer-Badge)
const otherActive = Object.entries(queueStatus).filter(([k, v]) => {
const kFocus = isMain ? '__main__' : focusedProjectId;
if (k === kFocus) return false;
return v.busy || v.queue_size > 0;
}).length;
return (
<View
style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 12, paddingVertical: 6,
backgroundColor: activeProject ? 'rgba(52,199,89,0.10)' : 'transparent',
borderBottomWidth: activeProject ? 2 : 1,
borderColor: activeProject ? '#34C759' : '#1E1E2E',
paddingHorizontal: 12, paddingVertical: 8,
backgroundColor: isMain ? '#1A1A26' : 'rgba(52,199,89,0.10)',
borderBottomWidth: 2,
borderColor: isMain ? '#1E1E2E' : '#34C759',
}}
>
<TouchableOpacity onPress={() => setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
</Text>
<Text style={{ fontSize: 11, color: '#555570', marginRight: 8 }}>
{activeProject ? 'wechseln ' : 'Projekte '}
</Text>
</TouchableOpacity>
{activeProject && (
<TouchableOpacity
onPress={() => {
brainApi.switchProject('').then(s => setActiveProject(s.active || null)).catch(() => {});
}}
style={{ paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.08)' }}
hitSlop={{top:8,bottom:8,left:8,right:8}}
onPress={() => setProjectsVisible(true)}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
hitSlop={{top:6,bottom:6,left:6,right:6}}
>
<Text style={{ fontSize: 11, color: '#E0E0F0', fontWeight: '600' }}>× Hauptchat</Text>
<Text style={{ fontSize: 22, color: '#E0E0F0', fontWeight: '700' }}></Text>
{otherActive > 0 && (
<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>
</View>
)}
</TouchableOpacity>
<View style={{ flex: 1, marginLeft: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text style={{ fontSize: 14, color: isMain ? '#E0E0F0' : '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
{isMain ? '💬 Hauptchat' : `📁 ${focusedName}`}
</Text>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
<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>
);
})()}
{/* Projekt-Modal */}
{/* Projekt-Drawer als Modal */}
<ProjectsBrowser
visible={projectsVisible}
onClose={() => setProjectsVisible(false)}
onActiveChanged={(p) => setActiveProject(p)}
onActiveChanged={(p) => setFocusedProjectId(p?.id || '')}
currentFocusId={focusedProjectId}
queueStatus={queueStatus}
/>
{/* Suchleiste mit Treffer-Navigation */}
@@ -2770,7 +2718,7 @@ const ChatScreen: React.FC = () => {
}, 300);
}}
keyExtractor={item => item.id}
renderItem={renderMessageWithProjectHeader}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
+5 -5
View File
@@ -735,11 +735,11 @@ const SettingsScreen: React.FC = () => {
brainApi.listProjects(true)
.then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name }))))
.catch(() => {});
// Default-Filter: aktives Projekt (falls vorhanden), sonst "alle".
brainApi.getProjectStatus()
.then(s => {
if (s.active_id) setFileFilterProjectId(s.active_id);
})
// Default-Filter: fokussiertes Projekt aus AsyncStorage (falls Stefan
// grade in einem drin ist), sonst "alle". Multi-Threading: Focus ist
// App-lokal, kein Brain-Query mehr.
AsyncStorage.getItem('aria_focused_project_id')
.then(pid => { if (pid) setFileFilterProjectId(pid); })
.catch(() => {});
}, [fileManagerOpen]);
+5
View File
@@ -982,6 +982,10 @@ class AudioService {
noSpeechTimeoutMs?: number;
endpointMs?: 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 }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
@@ -1055,6 +1059,7 @@ class AudioService {
endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500,
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
projectId: opts.projectId || '',
});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
+17
View File
@@ -169,6 +169,16 @@ export interface ProjectStatus {
projects: Project[];
}
/** Queue-Status pro Kontext — was gerade arbeitet, was wartet.
* Key "__main__" = Hauptchat, sonst project_id. */
export interface QueueContextStatus {
busy: boolean;
queue_size: number;
}
export interface ProjectQueueStatus {
contexts: Record<string, QueueContextStatus>;
}
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
export interface Skill {
name: string;
@@ -590,6 +600,13 @@ export const brainApi = {
body: patch,
});
},
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
getProjectQueueStatus(): Promise<ProjectQueueStatus> {
return _send('/projects/queue-status');
},
};
export default brainApi;
+66 -29
View File
@@ -836,10 +836,14 @@ META_TOOLS = [
"function": {
"name": "project_enter",
"description": (
"Wechselt in ein bestehendes Projekt. Fuzzy-Match auf Namen — "
"'Spotify' findet das Projekt 'Spotify-Setup'. Nach dem Eintritt "
"tagged jeder neue Turn die project_id. Bei sehr alten Projekten: "
"vorher project_summary aufrufen damit Du Stefan abholst."
"Signalisiert der App/Diagnostic 'wechsel zu diesem Projekt'. Fuzzy-"
"Match auf Namen — 'Spotify' findet das Projekt 'Spotify-Setup'. "
"Der AKTUELLE Turn bleibt aber in seinem Chat-Kontext — wir haben "
"Multi-Threading, kein globales 'aktives Projekt' mehr. Wenn Stefan "
"im Hauptchat sagt 'lass uns in Spotify weiter machen': "
"project_enter aufrufen (App wechselt Ansicht), aber Deine Antwort "
"geht trotzdem im Hauptchat raus. Bei sehr alten Projekten vorher "
"project_summary aufrufen damit Du Stefan abholst."
),
"parameters": {
"type": "object",
@@ -855,8 +859,10 @@ META_TOOLS = [
"function": {
"name": "project_exit",
"description": (
"Verlässt das aktuelle Projekt — zurück zum Hauptthread. Nutze "
"wenn Stefan sagt 'Projekt Ende', 'zurück zum Hauptchat' o.ä."
"Signalisiert der App/Diagnostic 'wechsel zurueck zum Hauptchat'. "
"Nutze wenn Stefan sagt 'Projekt Ende' oder 'zurueck zum Hauptchat' "
"waehrend er visuell in einem Projekt ist. Der aktuelle Turn bleibt "
"in seinem Chat-Kontext — Multi-Threading."
),
"parameters": {"type": "object", "properties": {}},
},
@@ -1051,7 +1057,21 @@ class Agent:
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
def chat(self, user_message: str, source: str = "") -> str:
def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None,
pending_queue: Optional[list[str]] = None) -> str:
"""Verarbeitet eine User-Nachricht — pro Request project_id explizit
angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
so laufen parallele /chat-Requests fuer verschiedene Projekte echt
parallel (Multi-Threading-Architektur seit 06/2026).
pending_queue: Liste weiterer User-Nachrichten die in DIESEM Projekt
NACH dem aktuellen Turn warten. ARIA sieht sie im System-Prompt und
soll pruefen ob eine spaetere Nachricht den aktuellen Task
korrigiert / annuliert (dann Skip-Antwort statt Ausfuehren).
Wenn project_id=None (Backward-Compat fuer Aufrufer die den Param nicht
setzen): wird als Hauptchat behandelt."""
user_message = (user_message or "").strip()
if not user_message:
raise ValueError("Leere Nachricht")
@@ -1059,9 +1079,8 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Aktives Projekt (leer = Hauptthread) — bestimmt das Tagging der
# neuen Turns + das Conversation-Window-Filter fuer den LLM-Prompt.
active_project_id = projects_mod.get_active()
# Projekt-Kontext pro Request statt aus globalem State
active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
@@ -1127,6 +1146,28 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
if pending_queue:
queue_lines = "\n".join(f" - {m[:280]}" for m in pending_queue[:5])
more_hint = ""
if len(pending_queue) > 5:
more_hint = f"\n ... und {len(pending_queue) - 5} weitere"
system_prompt += (
f"\n\n## QUEUE — NACH DIESEM TASK WARTEN\n"
f"{queue_lines}{more_hint}\n"
f"\nBEVOR DU DEN AKTUELLEN TASK LOESST:\n"
f" 1. Pruefe die Queue oben — widerspricht/annuliert eine der spaeteren "
f"Nachrichten den aktuellen Task?\n"
f" 2. Wenn ja: antworte ganz kurz 'Task ubersprungen — wird durch spaetere "
f"Nachricht korrigiert' und mach KEINE Aktion. Der spaetere Task laeuft dann "
f"ganz normal als naechste Anfrage durch.\n"
f" 3. Wenn nein / unabhaengige Ergaenzung: Task normal loesen.\n"
f"Beispiel: aktueller Task 'titelleiste rot', Queue enthaelt "
f"'doch nicht, mach sie blau' → skip, blau kommt als naechste Anfrage."
)
# Aktuelle Projekt-Bühne als System-Hinweis ergaenzen, damit Claude
# weiss in welchem Kontext sie spricht und ihre project_* Tools korrekt
# einsetzt (z.B. bei „Projekt Ende" project_exit aufruft).
@@ -1217,19 +1258,17 @@ class Agent:
err_text = f"[Fehler: {exc}]"
logger.error("chat() Exception — schreibe Error-Marker als Assistant-Turn: %s", exc)
try:
# Aktive Projekt-ID NEU lesen — kann sich waehrend des Tool-Loops
# geaendert haben (project_enter/exit als Tool-Call).
# Turn-Kontext bleibt gleich — es gibt keinen globalen Wechsel
# mehr, jeder Request laeuft in seinem eigenen project_id-Kontext.
self.conversation.add("assistant", err_text,
project_id=projects_mod.get_active())
project_id=active_project_id)
except Exception as add_exc:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# 7. Assistant-Turn (final reply) in die Conversation
# NEU lesen — wenn der LLM project_enter/exit gerufen hat, ist der
# Final-Reply schon im neuen Projekt-Kontext.
self.conversation.add("assistant", final_reply,
project_id=projects_mod.get_active())
project_id=active_project_id)
return final_reply
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -1804,7 +1843,10 @@ class Agent:
"project": p,
"action": "created",
})
return f"OK — Projekt '{p['name']}' angelegt (id={p['id']}) und aktiv. Alle weiteren Turns gehen jetzt da rein bis Du project_exit oder project_enter aufrufst."
return (f"OK — Projekt '{p['name']}' angelegt (id={p['id']}). App/Diagnostic "
f"kriegen ein project_changed-Event und koennen dahin wechseln. "
f"Kommender Turn bleibt aber im aktuellen Chat-Kontext — "
f"Multi-Threading, jeder Chat ist eigenstaendig.")
if name == "project_enter":
pname = (arguments.get("name") or "").strip()
if not pname:
@@ -1812,7 +1854,6 @@ class Agent:
p = projects_mod.find_project(pname)
if not p:
return f"Kein Projekt '{pname}' gefunden. Nutze project_list zum Aufzaehlen oder project_create wenn's neu sein soll."
projects_mod.set_active(p["id"])
self._pending_events.append({
"type": "project_changed",
"project": p,
@@ -1822,31 +1863,27 @@ class Agent:
hint = ""
if turn_count > 0:
hint = " Wenn Stefan nach dem Stand fragt: project_summary aufrufen."
return f"OK — in Projekt '{p['name']}' eingestiegen (id={p['id']}, {turn_count} bisherige Turns).{hint}"
return (f"OK — App/Diagnostic wird zum Projekt '{p['name']}' "
f"(id={p['id']}, {turn_count} bisherige Turns) umschalten. "
f"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.{hint}")
if name == "project_exit":
active_id = projects_mod.get_active()
if not active_id:
return "Es ist gerade kein Projekt aktiv — bereits im Hauptthread."
p = projects_mod.get_project(active_id)
projects_mod.set_active("")
self._pending_events.append({
"type": "project_changed",
"project": p,
"project": None,
"action": "exited",
})
return f"OK — Projekt '{p['name'] if p else active_id}' verlassen. Zurueck im Hauptthread."
return ("OK — App/Diagnostic bekommt Signal 'zurueck zum Hauptchat'. "
"Der aktuelle Turn bleibt aber im aktuellen Chat-Kontext.")
if name == "project_list":
items = projects_mod.list_projects()
if not items:
return "(keine Projekte angelegt)"
active_id = projects_mod.get_active()
lines = []
for p in items:
marker = " ← AKTIV" if p["id"] == active_id else ""
status_lbl = p.get("status", "active")
lines.append(
f"- {p['name']} (id={p['id']}, {p.get('turn_count', 0)} Turns, "
f"status={status_lbl}){marker}"
f"status={status_lbl})"
)
return "Projekte:\n" + "\n".join(lines)
if name == "project_summary":
+112 -8
View File
@@ -112,6 +112,17 @@ async def lifespan(app: FastAPI):
except Exception as 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))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
@@ -607,6 +618,11 @@ def memory_import_bootstrap(body: BootstrapBundle):
class ChatIn(BaseModel):
message: str
source: str = "" # "app" / "diagnostic" / "stt" — optional
# Multi-Threading: Client bestimmt pro Request welches Projekt (leer = Hauptchat).
# Kein globaler active_project-State mehr im Brain — parallele Requests fuer
# verschiedene Projekte laufen echt parallel, nur Requests fuers gleiche
# Projekt queuen (per-Projekt-Lock).
project_id: str = ""
class ChatOut(BaseModel):
@@ -614,20 +630,96 @@ class ChatOut(BaseModel):
turns: int
distilling: bool
events: list = Field(default_factory=list)
# Aktive Projekt-ID NACH dem Turn (kann durch project_enter/exit-Tools
# waehrend des Turns gewechselt haben). Bridge gibt das an die Chat-
# Bubble-Broadcasts weiter damit App + Diagnostic die Nachricht zum
# richtigen Projekt-Block sortieren koennen.
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
project_id: str = ""
# Per-Projekt async-Locks fuer Queue-Behavior: Requests fuers gleiche Projekt
# warten aufeinander (queue), Requests fuer verschiedene Projekte laufen echt
# parallel. Hauptchat = Lock unter key "" (leerer String).
_project_locks: dict[str, asyncio.Lock] = {}
_project_locks_meta_lock = asyncio.Lock()
# Pro Projekt eine Liste noch-nicht-verarbeiteter Requests. Wird beim Enqueue
# ergaenzt, beim Fertig-Werden gepoppt. Ermoeglicht Queue-Aware-Prompting:
# waehrend ARIA an Task N arbeitet, sieht sie N+1..N+k als System-Prompt-Hinweis
# und kann entscheiden ob eine spaetere Nachricht die aktuelle korrigiert/
# annuliert → dann Skip-Antwort statt Ausfuehren.
_project_pending: dict[str, list[dict]] = {}
async def _get_project_lock(project_id: str) -> asyncio.Lock:
"""Holt (oder erzeugt) den asyncio.Lock fuer ein bestimmtes Projekt.
Nutzt _project_locks_meta_lock zur Vermeidung von Race Conditions
beim ersten-Zugriff pro Projekt."""
async with _project_locks_meta_lock:
lock = _project_locks.get(project_id)
if lock is None:
lock = asyncio.Lock()
_project_locks[project_id] = lock
return lock
def _project_queue_snapshot() -> dict:
"""Snapshot fuer /projects/queue-status: welche Projekte arbeiten gerade,
wieviele wait-in-queue haben, welche sind idle."""
out = {}
# Zeige nur Kontexte mit Aktivitaet — locked oder pending
seen: set = set()
for pid, lock in _project_locks.items():
pending = len(_project_pending.get(pid, []))
is_busy = lock.locked()
# busy: gerade in Verarbeitung. queue: N weitere warten dahinter.
# Der Busy-Request zaehlt NICHT in queue (er ist ja aus pending schon "raus").
out[pid or "__main__"] = {
"busy": is_busy,
"queue_size": max(0, pending - (1 if is_busy else 0)),
}
seen.add(pid)
for pid, pend in _project_pending.items():
if pid in seen:
continue
out[pid or "__main__"] = {"busy": False, "queue_size": len(pend)}
return out
@app.post("/chat", response_model=ChatOut)
def chat(body: ChatIn, background: BackgroundTasks):
async def chat(body: ChatIn, background: BackgroundTasks):
"""Hauptpfad. Antwort kommt synchron. Memory-Destillat laeuft
im Hintergrund nachdem die Response rausging."""
im Hintergrund nachdem die Response rausging.
Multi-Threading: Requests fuers gleiche Projekt (project_id gleich)
laufen serialisiert durch den per-Projekt-Lock — Queue-Behavior.
Verschiedene Projekte laufen parallel."""
pid = (body.project_id or "").strip()
lock = await _get_project_lock(pid)
# Vor dem Lock in die Pending-Liste, damit die verlaufende Task sehen kann
# was NACH ihr in der Warteschlange steht (Queue-Aware Prompting).
import uuid as _uuid
req_id = _uuid.uuid4().hex
_project_pending.setdefault(pid, []).append({
"id": req_id, "message": body.message, "source": body.source,
})
try:
async with lock:
# Snapshot: was liegt NACH mir in der Queue?
after_me = [
e["message"] for e in _project_pending.get(pid, [])
if e["id"] != req_id
]
a = agent()
try:
reply = a.chat(body.message, source=body.source)
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
pending_queue=after_me,
),
)
except ValueError as exc:
raise HTTPException(400, str(exc))
except RuntimeError as exc:
@@ -642,8 +734,20 @@ def chat(body: ChatIn, background: BackgroundTasks):
turns=len(a.conversation.turns),
distilling=needs_distill,
events=a.pop_events(),
project_id=projects_mod.get_active(),
project_id=pid,
)
finally:
_project_pending[pid] = [
e for e in _project_pending.get(pid, []) if e["id"] != req_id
]
@app.get("/projects/queue-status")
def projects_queue_status():
"""Snapshot: fuer jeden Projekt-Kontext (inkl. Hauptchat unter __main__)
- busy: True wenn gerade ein Request in Verarbeitung
- queue_size: wieviele weitere warten dahinter"""
return {"contexts": _project_queue_snapshot()}
# ── Projekte ────────────────────────────────────────────────────────
+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)
+205 -14
View File
@@ -611,6 +611,18 @@ class ARIABridge:
self._last_chat_final_at: float = 0.0
# requestId → messageId Map fuer XTTS-Audio-Cache (App-seitige Zuordnung)
self._xtts_request_to_message: dict[str, str] = {}
# Voice-Router (Multi-Threading, 06/2026): sticky Projekt-Kontext fuer
# STT-Voice-Nachrichten. Wechselt via „fuer <name>:"-Prefix, faellt nach
# STICKY_TIMEOUT_SEC ohne neue Voice-Message zurueck auf Hauptchat.
# Meta-Kommandos („zurueck zum hauptchat") werden client-seitig
# interceptiert und aendern hier den Sticky OHNE Brain-Roundtrip.
self._voice_sticky_project_id: str = ""
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.
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
@@ -1216,6 +1228,10 @@ class ARIABridge:
server_path = f.get("serverPath")
if turn_pid and server_path:
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)
# Bei fehlenden Files: User informieren (sonst sieht er nur stille
# Verluste — ARIA hat den Marker hingeschrieben aber das File nicht
@@ -1522,24 +1538,21 @@ class ARIABridge:
"""
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
url = f"{brain_url}/chat"
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
# project_id kommt jetzt IM /chat-Body an das Brain (Multi-Threading:
# per-Request-Routing statt globaler active_project-State).
payload = json.dumps({
"message": text, "source": source,
"project_id": project_id or "",
}).encode("utf-8")
logger.info("[brain] chat ← %s '%s' project=%s", source, text[:80], project_id or "(main)")
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
# / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern
# damit die App beim chat_history_response ihre lokale Bubble
# dedupen kann (sonst verschwindet sie nach Offline→Online-Race).
# project_id: pre-turn-State (was App geschickt hat). Wenn leer, vom
# Brain-Status nachholen — z.B. bei Trigger-Replies oder Diagnostic-Send.
entry: dict = {"role": "user", "text": text, "source": source}
if client_msg_id:
entry["clientMsgId"] = client_msg_id
if not project_id:
try:
with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=3) as r:
project_id = (json.loads(r.read()).get("active_id") or "")
except Exception:
pass
if project_id:
entry["project_id"] = project_id
self._append_chat_backup(entry)
@@ -2784,6 +2797,41 @@ class ARIABridge:
future.set_result(text)
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":
# Phase 2 Brain-Shortcut: die whisper-bridge hat im Streaming-Modus
# einen Endpoint erkannt und schickt den finalen Text direkt.
@@ -2832,9 +2880,15 @@ class ARIABridge:
if self._is_duplicate_client_msg(client_msg_id):
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(
text, interrupted, audio_request_id, location,
client_msg_id=client_msg_id))
client_msg_id=client_msg_id,
focused_project_id=focused_pid))
return
elif msg_type == "oauth_callback":
@@ -2979,27 +3033,163 @@ class ARIABridge:
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
# Voice-Router-Konstanten
_VOICE_STICKY_TIMEOUT_SEC = 30.0
_VOICE_META_BACK_TO_MAIN = re.compile(
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,
)
_VOICE_META_PROJECT_PREFIX = re.compile(
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
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
an welchen Projekt-Kontext.
Returns (should_forward, cleaned_text, project_id, meta_action):
- should_forward=False: reines Meta-Kommando, kein Brain-Call.
meta_action beschreibt was passiert ist (broadcastet an UI).
- should_forward=True: cleaned_text ans Brain, project_id ist Focus.
Bei Prefix wird der Prefix aus dem Text entfernt.
Prioritaets-Reihenfolge:
1. Meta „zurueck zum hauptchat" → Sticky reset, kein Forward.
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
now = _time.time()
stripped = text.strip()
# 1) Meta: zurueck zum Hauptchat
if self._VOICE_META_BACK_TO_MAIN.match(stripped):
self._voice_sticky_project_id = ""
self._voice_sticky_expires_at = 0.0
return (False, "", "", "back_to_main")
# 2) Prefix: "fuer <name>: <text>"
m = self._VOICE_META_PROJECT_PREFIX.match(stripped)
if m:
name = m.group(1)
remainder = m.group(2).strip()
# Fuzzy-Match auf Projekt via Brain-API
try:
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
with urllib.request.urlopen(f"{brain_url}/projects/list", timeout=3) as r:
projects = json.loads(r.read()).get("projects", [])
from difflib import SequenceMatcher
best, best_score = None, 0.0
q = name.lower()
for p in projects:
pname = p.get("name", "").lower()
if q == pname or q == p.get("id", ""):
best, best_score = p, 1.0
break
s = SequenceMatcher(None, q, pname).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
pid = best["id"]
self._voice_sticky_project_id = pid
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
logger.info("[voice-router] Prefix → Projekt '%s' (id=%s, score=%.2f)",
best.get("name"), pid, best_score)
return (True, remainder or stripped, pid, "project_prefix")
except Exception as exc:
logger.warning("[voice-router] Prefix-Match fehlgeschlagen: %s", exc)
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
# 3) Kein Meta / Prefix → Sticky oder Default
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
# Sticky refreshen
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
return (True, stripped, self._voice_sticky_project_id, "sticky")
# Sticky abgelaufen — zurücksetzen
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")
async def _process_endpoint_text(self, text: str,
interrupted: bool = False,
audio_request_id: str = "",
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
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
Spiegel-Methode zu _process_app_audio NACH dem STT-Schritt. Bewusst
eigene Methode statt Code-Pfade in _process_app_audio aufdroeseln,
damit der Legacy-Pfad (App schickt 'audio') unangetastet bleibt.
Voice-Router: interceptiert Meta-Kommandos (zurueck zum Hauptchat)
+ Prefix-Adressierung („fuer Frankreich: ...") + 30s-Sticky. Meta
selbst geht NICHT ans Brain, sondern broadcastet als project_changed-
Event → App+Diagnostic wechseln den Focus.
"""
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"):
# UI-Focus-Update broadcasten
payload = {"action": "entered" if meta_action == "project_prefix" else "exited"}
if meta_action == "project_prefix" and project_id:
# Namen aus dem Cache holen — best effort
try:
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
with urllib.request.urlopen(f"{brain_url}/projects/list", timeout=2) as r:
for p in json.loads(r.read()).get("projects", []):
if p.get("id") == project_id:
payload["id"] = project_id
payload["name"] = p.get("name", "")
break
except Exception:
payload["id"] = project_id
await self._send_to_rvs({
"type": "project_changed",
"payload": payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
if not should_forward:
logger.info("[voice-router] Meta-Kommando '%s' intercepted, kein Brain-Call",
meta_action)
return
try:
stt_payload = {
"text": text,
"text": cleaned,
"sender": "stt",
}
if audio_request_id:
stt_payload["audioRequestId"] = audio_request_id
if location:
stt_payload["location"] = location
if project_id:
stt_payload["projectId"] = project_id
ok = await self._send_to_rvs({
"type": "chat",
"payload": stt_payload,
@@ -3012,10 +3202,11 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
core_text = self._build_core_text(text, interrupted, location)
core_text = self._build_core_text(cleaned, interrupted, location)
await self.send_to_core(core_text,
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id)
client_msg_id=client_msg_id,
project_id=project_id)
async def _stt_remote(self, audio_b64: str, mime_type: str) -> Optional[str]:
"""Schickt Audio an die whisper-bridge und wartet auf stt_response.
+128 -4
View File
@@ -305,6 +305,12 @@
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
</div>
</div>
<!-- Multi-Threading: Kontext-Strip ueber dem Chat. Jeder Kontext
(Hauptchat + aktive Projekte) als kompakte Karte mit Status-Dot.
Tap wechselt den Focus — Chat-Box filtert dann auf diesen Kontext. -->
<div id="chat-context-strip" style="display:flex;gap:6px;overflow-x:auto;padding:6px 4px;margin-bottom:6px;border-bottom:1px solid #1E1E2E;">
<!-- wird von renderContextStrip() befuellt -->
</div>
<div class="chat-box" id="chat-box"></div>
<div id="thinking-indicator" style="display:none;padding:6px 10px;font-size:12px;color:#FFD60A;background:#1E1E2E;border-radius:0 0 6px 6px;margin-top:-8px;margin-bottom:8px;align-items:center;justify-content:space-between;">
<span><span style="animation:pulse 1s infinite;">&#x1F4AD;</span> <span id="thinking-text">ARIA denkt...</span></span>
@@ -1317,6 +1323,94 @@
<script>
const chatBox = document.getElementById('chat-box');
// ── Multi-Threading: Kontext-Focus fuer Diagnostic-Chat ─────
// focusedContextId: leerer String = Hauptchat, sonst project_id.
// Gefiltert werden Bubbles per data-project-id-Match (siehe addChat).
// Send-Input uebergibt die Focus-ID ans Brain (via bridge → /chat).
let focusedContextId = localStorage.getItem('diag_focused_context_id') || '';
let diagQueueStatus = {};
let diagProjectsCache = [];
function updateChatVisibilityByFocus() {
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
if (!box) continue;
for (const el of box.querySelectorAll('.chat-msg')) {
const pid = el.dataset.projectId || '';
el.style.display = (pid === focusedContextId) ? '' : 'none';
}
box.scrollTop = box.scrollHeight;
}
}
function switchDiagFocus(id) {
focusedContextId = id || '';
localStorage.setItem('diag_focused_context_id', focusedContextId);
updateChatVisibilityByFocus();
renderContextStrip();
}
function renderContextStrip() {
const strip = document.getElementById('chat-context-strip');
if (!strip) return;
const chip = (id, name, isFocus, dotColor, subline) => {
const bg = isFocus ? 'rgba(52,199,89,0.15)' : '#1E1E2E';
const border = isFocus ? '#34C759' : '#2A2A3E';
return `<div onclick="switchDiagFocus('${id}')" style="cursor:pointer;flex:0 0 auto;padding:6px 10px;background:${bg};border:1px solid ${border};border-radius:6px;display:flex;align-items:center;gap:6px;min-width:120px;">
<div style="width:8px;height:8px;border-radius:4px;background:${dotColor};"></div>
<div style="display:flex;flex-direction:column;min-width:0;">
<div style="color:${isFocus?'#34C759':'#E0E0F0'};font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">${escapeHtml(name)}</div>
<div style="color:#8888AA;font-size:10px;">${subline}</div>
</div>
</div>`;
};
const dotFor = (key) => {
const s = diagQueueStatus[key];
if (!s) return { color: '#555570', label: '' };
if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' };
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
return { color: '#34C759', label: 'idle' };
};
const cards = [];
// Hauptchat
const mainDot = dotFor('__main__');
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet
for (const p of diagProjectsCache) {
if (p.status === 'archived') continue;
const d = dotFor(p.id);
const sub = d.label || `${p.turn_count} Turns`;
cards.push(chip(p.id, `📁 ${p.name}`, focusedContextId === p.id, d.color, sub));
}
strip.innerHTML = cards.join('');
}
async function refreshDiagQueueStatus() {
try {
const r = await fetch('/api/brain/projects/queue-status');
const d = await r.json();
diagQueueStatus = d?.contexts || {};
renderContextStrip();
} catch {}
}
async function refreshDiagProjectsCache() {
try {
const r = await fetch('/api/brain/projects/list?include_archived=false');
const d = await r.json();
diagProjectsCache = d?.projects || [];
renderContextStrip();
} catch {}
}
// Beim Load: Projekte laden + Polling starten
setTimeout(() => {
refreshDiagProjectsCache();
refreshDiagQueueStatus();
setInterval(refreshDiagQueueStatus, 2000);
// Projekt-Liste alle 15s neu holen (neue Anlagen, umbenennen)
setInterval(refreshDiagProjectsCache, 15000);
}, 500);
const pauseHint = document.getElementById('pause-hint');
const btnScroll = document.getElementById('btn-scroll');
let ws;
@@ -1657,7 +1751,11 @@
}
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;
}
if (msg.type === 'file_from_aria') {
@@ -1723,6 +1821,7 @@
location: p.location,
ttsText: p.ttsText,
backupTs: p.backupTs,
projectId: p.projectId || '',
});
return;
}
@@ -1811,7 +1910,7 @@
const m = msg.messages[mi];
try {
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;
}
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
@@ -1829,6 +1928,11 @@
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
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;
b.appendChild(el);
}
@@ -1850,6 +1954,9 @@
}
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) {
console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`);
}
@@ -1911,8 +2018,10 @@
if (!text && diagPendingFiles.length === 0) return;
if (diagPendingFiles.length > 0) sendDiagAttachments();
if (text) {
addChat('sent', text, 'via RVS');
send({ action: 'test_rvs', text });
// Multi-Threading: mit fokussierter Kontext-ID senden.
// Bridge routet an /chat body.project_id — Brain queued per Kontext.
addChat('sent', text, 'via RVS', { projectId: focusedContextId });
send({ action: 'test_rvs', text, projectId: focusedContextId });
}
input.value = '';
}
@@ -2178,12 +2287,18 @@
// Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' });
// Projekt-Tag fuer Focus-Filter (Multi-Threading, 06/2026)
const projectId = (options && options.projectId) || '';
const hiddenByFocus = (typeof focusedContextId === 'string' && projectId !== focusedContextId);
// In beide Chat-Boxen schreiben (normal + Vollbild)
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
if (!box) continue;
const el = document.createElement('div');
el.className = `chat-msg ${type}`;
if (backupTs) el.dataset.ts = String(backupTs);
el.dataset.projectId = projectId;
if (hiddenByFocus) el.style.display = 'none';
el.innerHTML = html;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
@@ -2228,6 +2343,15 @@
const el = document.createElement('div');
el.className = 'chat-msg received';
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';
el.innerHTML = html;
box.appendChild(el);
+20 -10
View File
@@ -660,11 +660,11 @@ function handleGatewayMessage(msg) {
broadcast({ type: "agent_activity", activity: "idle" });
pendingMessageTime = 0; // Watchdog: Antwort erhalten
updateAgentActivity();
// Antwort in Backup-Log schreiben
try {
const entry = JSON.stringify({ ts: Date.now(), role: "assistant", text: text.slice(0, 2000), session: activeSessionKey }) + "\n";
fs.appendFileSync("/shared/config/chat_backup.jsonl", entry);
} catch {}
// KEIN chat_backup-Write mehr hier: die Bridge (_process_core_response)
// ist der massgebliche Writer und schreibt den Assistant-Eintrag MIT
// project_id. Dieser Gateway-Watch-Pfad kennt die project_id nicht —
// ein Write hier erzeugte ein untagged Duplikat, das beim Reload im
// Hauptchat auftaucht (statt im Projekt).
return;
}
@@ -1001,18 +1001,26 @@ function sendToRVS_raw(msgObj) {
freshWs.on("error", () => {});
}
function sendToRVS(text, isTrace) {
function sendToRVS(text, isTrace, projectId) {
// Brain-Pipeline: Diagnostic → RVS → Bridge → Brain (HTTP). OpenClaw-
// Gateway-Pfad ist abgeschaltet. Sender 'diagnostic' damit die Bridge
// den Text als User-Nachricht ans Brain weiterleitet und die App +
// Diagnostic die Bubble live spiegeln koennen.
//
// projectId (Multi-Threading 06/2026): optional — leerer/undefined String
// = Hauptchat, sonst project_id. Bridge liest payload.projectId und routet
// an /chat body.project_id — Brain queued per Kontext.
if (!rvsWs || rvsWs.readyState !== WebSocket.OPEN) {
if (isTrace) traceEnd(false, "RVS nicht verbunden");
return false;
}
sendToRVS_raw({
type: "chat",
payload: { text, sender: "diagnostic" },
payload: {
text,
sender: "diagnostic",
projectId: projectId || "",
},
timestamp: Date.now(),
});
return true;
@@ -2285,7 +2293,7 @@ wss.on("connection", (ws) => {
sendToRVS(msg.text || "aria lebst du noch?", true);
} else if (msg.action === "test_rvs") {
traceStart("RVS", msg.text || "aria lebst du noch?");
sendToRVS(msg.text || "aria lebst du noch?", true);
sendToRVS(msg.text || "aria lebst du noch?", true, msg.projectId || "");
} else if (msg.action === "reconnect_gateway") {
connectGateway();
} else if (msg.action === "reconnect_rvs") {
@@ -2738,8 +2746,9 @@ async function handleLoadChatHistory(clientWs) {
if (obj.role !== "user" && obj.role !== "assistant") continue;
const ts = obj.ts || 0;
const text = String(obj.text || "");
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
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;
}
// assistant: nach FILE-Markern scannen, eigene aria_file-Eintraege pro Datei
@@ -2761,9 +2770,10 @@ async function handleLoadChatHistory(clientWs) {
size,
ts,
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 }));
+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_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:
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
@@ -310,6 +322,7 @@ class StreamSession:
last_partial: str = ""
last_growth_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
endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
@@ -533,6 +546,35 @@ class SessionManager:
if audio_ms < STREAM_MIN_AUDIO_MS:
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
since_last = (now - sess.last_transcribe_at) * 1000.0
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
@@ -568,18 +610,8 @@ class SessionManager:
})
await _debug_log(ws, "stream.partial",
f"id={sess.request_id[:12]} text={text[:80]!r}")
else:
# Stagnation pruefen — Endpoint-Bedingung
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")
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
# semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun.
def _buffer_duration_ms(self, sess: StreamSession) -> float:
# 16-bit s16le mono → 2 bytes pro Sample
@@ -588,6 +620,23 @@ class SessionManager:
return 0.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:
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
feuert stt_endpoint + stt_stream_done, droppt Session."""