Compare commits

...

17 Commits

Author SHA1 Message Date
duffyduck 579a466402 release: bump version to 0.1.4.9 2026-05-15 11:45:53 +02:00
duffyduck 5133f0bc2d fix(chat): User-Bubble →failed bei langsamen ARIA-Antworten
Symptom: ARIA bearbeitet die Nachricht (im Gedanken-Stream sichtbar),
aber unter der User-Bubble bleibt die Sanduhr stehen und nach ~90 s
springt sie auf ⚠ failed. ARIA-Antwort kommt trotzdem irgendwann durch
— die Bubble war also nie weg, nur visuell schief.

Wurzel: chat_ack vom Bridge kam offenbar in manchen Faellen nicht
verlaesslich an. ACK-Timer (30 s × 3 Retries) lief durch → 'failed'.

Fix: agent_activity = thinking/tool/assistant ist impliziter Beweis,
dass das Brain die Nachricht bekommen und angefangen hat zu arbeiten.
Beim ersten non-idle Event:
- alle laufenden ACK-Timer cancelen
- alle 'sending'-User-Bubbles auf 'sent' (✓) setzen

ARIA-Reply markiert dann wie gehabt 'delivered' (✓✓). Damit kann keine
Bubble mehr auf failed gehen waehrend Brain noch laeuft.

Plus: ACK_TIMEOUT_MS 30 → 60 s als Backup-Reserve fuer den Fall dass
weder ACK noch agent_activity ankommt (sehr unwahrscheinlich, aber
billig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:43:04 +02:00
duffyduck a476a4b734 release: bump version to 0.1.4.8 2026-05-15 11:28:06 +02:00
duffyduck 11b205ddaf fix(chat): chat_backup ts auf UNIX-ms umgestellt + Doppelpost-Schutz
Bug-1: _append_chat_backup nutzte asyncio.get_event_loop().time() —
das ist Container-Monotonic (bei Restart wieder 0), NICHT UNIX-Zeit.
Bridge schrieb so Eintraege mit ts wie 394M (=6.5 min Uptime), App-side
generiert User-Bubbles mit Date.now() = 1.778e12. Beim Sortieren in
der App: Server-Bubbles landeten alle als "uralt" (kleine ts) ueber den
lokalen Bubbles und teilweise unter dem 500er-Cap raus — Symptom:
"alles nach Hello Kitty fehlt in der App".

Fix: _append_chat_backup nutzt jetzt time.time() * 1000 (UNIX-ms).

Bug-2: doppelte User-Bubble nach App-Hintergrund/Restart mit Retry-Knopf.
Race-Fix von vorhin (text+timestamp-Heuristik, 5-Min-Fenster) griff
nicht weil bei kaputten Server-ts (394M) und lokalen UNIX-ms (1.778e12)
das Diff 1.7 Billionen ms war → Fenster nie zutreffend → lokale Bubble
blieb als Duplikat.

Fix: Text-Match alleine reicht — wenn der Server irgendwo eine
textgleiche User-Bubble hat, ist es dieselbe Nachricht. Greift jetzt
unabhaengig von ts-Konsistenz.

Plus: tools/migrate_chat_backup_ts.py — repariert vorhandene jsonl
(284 von 299 Eintraege auf der VM hatten Container-Uptime-ts). Datei-
Reihenfolge bleibt erhalten (war eh chronologisch), ts werden ab File-
Mtime rueckwaerts 60s-Schritten vergeben. Idempotent, .bak-Backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:26:39 +02:00
duffyduck 71c60ade8a release: bump version to 0.1.4.7 2026-05-15 11:11:33 +02:00
duffyduck bf3dc635d9 feat(brain): Live-Tool-Events im Gedanken-Stream
Proxy-Patch hookt Claude-CLI `assistant`-Events: bei jedem tool_use-
Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge
gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an die
RVS-Clients. App- und Diagnostic-Gedanken-Stream zeigen damit live mit
was ARIA gerade macht — vorher kam pro Brain-Call nur EIN „💭 denkt"
am Anfang und EIN „✓ fertig" am Ende.

Drei neue Bausteine:
- proxy-patches/routes.js: kompletter Replacement der npm-Version mit
  `_attachToolHook(subprocess)` — feuert pro tool_use-Block ein HTTP-
  POST an http://aria-bridge:8090/internal/agent-activity (URL via
  ARIA_TOOL_HOOK_URL Env-Variable ueberschreibbar). Fire-and-forget,
  fail-open — Brain-Call bricht NICHT ab wenn Bridge mal nicht da ist.
- docker-compose.yml: vierter cp-Schritt im proxy-Service kopiert
  routes.js ueber die npm-Version (analog zu openai-to-cli + cli-to-
  openai).
- bridge/aria_bridge.py: neuer `/internal/agent-activity`-Endpoint im
  bestehenden _serve_internal_http. Plus _emit_activity hat jetzt
  force=True-Param damit wiederholte gleiche Tool-Aufrufe (3x Bash in
  Folge) als drei Eintraege im Stream sichtbar bleiben.

App + Diagnostic: pushThought-Dedup laesst tool-Events durch (3x Bash
hintereinander gibt 3 Eintraege im Gedanken-Stream).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:07:39 +02:00
duffyduck 8ca899aaf5 release: bump version to 0.1.4.6 2026-05-15 10:59:10 +02:00
duffyduck 15facf48eb fix(bridge): send_to_core als create_task — RVS-recv blockt nicht mehr
Live-Diagnose nach dem Timeout-Bump: Bridge-Brain-Call rennt jetzt zwar
20 Min — aber nach ~4 Min droppt der RVS-Server die WebSocket-Verbindung.
Symptom in App+Diagnostic: "denkt einfach abgebrochen".

Ursache: `async for raw_message in ws: await _handle_rvs_message(...)` —
das await blockt den recv-Loop solange send_to_core laeuft (bis zu 20
Min). Der mobil.hacker-net.de:444 RVS-Server droppt Verbindungen ohne
echte App-Frames nach ~4 Min als idle-Timeout. Die websockets-Lib
beantwortet Pings im Hintergrund, aber das reicht offenbar nicht — der
Server zaehlt nur Application-Frames.

Fix: chat-Handler ruft send_to_core als asyncio.create_task statt await.
Brain laeuft im Hintergrund-Task, RVS-recv-Loop bleibt frei, neue
Messages werden weiter verarbeitet, Verbindung bleibt lebendig. Gleicher
Fix in _flush_pending_files_with_text und file-empty-Edge-Case.

Tradeoff: parallele Brain-Calls wenn der User waehrend einer laufenden
Antwort schnell mehrere Nachrichten schickt. Brain (FastAPI) verarbeitet
beide, conversation.jsonl koennte racen. App macht aber bereits Barge-In
via cancel_request bei Folge-Nachrichten — in der Praxis treffen sich
parallele Calls selten. Wenn doch Probleme: Bridge-Side asyncio.Lock um
send_to_core in einer Folge-Etappe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:50:46 +02:00
duffyduck 71fc90fcb8 fix(brain): Timeouts 5min → 20min — verkettete Timeouts haben lange
Multi-Tool-Sessions chronisch gekappt

Live-Diagnose auf der VM: drei verkettete 5-Min-Timeouts feuern bei
jedem laengeren Brain-Call exakt gleichzeitig:

  06:16:02  Brain → Proxy /v1/chat/completions
  06:20:53  Bridge kappt (4m51s, urlopen timeout=300)
  06:21:02  Brain bekommt HTTP 500 vom Proxy ('timed out after 300000ms')

Stefan's Karten-Rekonstruktion (curl gegen Nominatim/OSRM + viele Bash-
Tool-Calls + DB-Inserts) braucht locker 8–15 Min — alle Brain-Calls
ueber 5 Min sind reihenweise mit 'Brain-Fehler: timed out' verreckt,
auch wenn die Arbeit zu 80% durch war.

Drei Stellen patchen:
- bridge/aria_bridge.py: urlopen 300 → 1200 (20 Min)
- aria-brain/proxy_client.py: PROXY_TIMEOUT_SEC default 300 → 1200
- docker-compose.yml: dritter sed-Patch im proxy-Service
  setzt DEFAULT_TIMEOUT im claude-max-api-proxy von 300000 auf 1200000

Plus App-Watchdog: 180s → 1260s (21 Min, knapp ueber Brain-Timeout)
damit der lokale Stuck-Watchdog nicht waehrend legitimer langer
Sessions feuert. Echte Verbindungsabbrueche kappen vorher per WS-
Disconnect.

UX-Tradeoff bewusst akzeptiert: User sieht jetzt bis zu 20 Min nur
'ARIA denkt...' ohne Zwischen-Updates. Echte Loesung waere Streaming
oder async-Job-API (siehe Etappe B/C im Vorschlag) — das ist groesseres
Refactoring, hier reicht erst mal der Quick-Fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:40:26 +02:00
duffyduck 856701fb6f feat(chat): Gedanken-Stream (App + Diagnostic)
Persistentes chronologisches Log was ARIA intern macht — gefuettert aus
agent_activity-Events (thinking/tool/assistant/idle). Bleibt zwischen
Denk-Phasen stehen, neue Eintraege kommen unten dran, lange Pausen
werden mit Trennlinie + Minuten-Hint sichtbar gemacht.

App (ChatScreen.tsx):
- 💭-Icon in der Statusleiste neben 🗂️ und 🔍, zeigt Eintrags-Anzahl
- Bottom-Sheet (60% Hoehe) mit chronologischer Liste, Tap auf Hintergrund
  schliesst, 🗑-Confirm zum Leeren
- Persistierung in AsyncStorage (aria_thought_stream, capped 500)
- Dedup gegen direkt aufeinanderfolgende identische Events

Diagnostic (index.html):
- 💭 Gedanken-Button im Chat-Test-Header neben „Vollbild"
- Zentrales Modal (720px x 70vh), Live-Update wenn neue Eintraege kommen
  (autoscroll ans Ende), 🗑 Leeren-Button mit Confirm
- Persistierung in localStorage, gleiche cap/dedup-Logik wie App

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:31:55 +02:00
duffyduck 6037b62612 fix(brain): ARIA legt nicht mehr ungefragt Skills an
Prompt sagte 'Harte Regel — IMMER Skill anlegen wenn pip-Library
noetig'. ARIA hat das wortwoertlich genommen: bei einer einfachen
pdf-extract-Frage hat sie sofort skill_create gerufen → Brain blockiert
12 Min im venv+pip-Install-subprocess.run, App zeigt 'ARIA denkt',
Diagnostic emitted nach 5 Min Timeout idle, Stefan blieb stundenlang
ohne Antwort.

Neue Regel:
- Goldene Regel: NIE ungefragt Skills anlegen.
- Aufgabe zuerst inline loesen (Bash, direkter pip install, Workaround).
- Skill nur wenn Stefan EXPLIZIT sagt 'mach daraus einen Skill' /
  'leg den als Skill an'.
- Die vier Kriterien (wiederkehrend/nicht-trivial/parametrisierbar/
  wiederverwendbar) sind jetzt Checkliste NACH expliziter Anfrage —
  fehlt eines, soll ARIA nachfragen statt blind anzulegen.
- Begruendung steht jetzt im Prompt: Setup blockt Brain bis zu 12 Min.

Greift auf der VM ohne Re-Build, prompts.py wird beim Start geladen
(docker compose restart aria-brain reicht).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:14:33 +02:00
duffyduck 8f88cb0030 fix(chat): Doppel-Bubble nach Retry + verwaiste ACK-Timer + docs
Race nach Etappe-3-Reconnect-Fix: lokale failed-Bubble (mit clientMsgId)
und Server-Backup-Eintrag (ohne clientMsgId, aus alter Bridge-Version)
landeten beide im Merge → User sah Doppelpost: einmal ueber der
ARIA-Antwort (Server), einmal mit Retry-Knopf darunter (lokal). Plus
ACK-Timer konnte weiterlaufen obwohl die Bubble schon delivered war —
Retry pushte den Status zurueck auf sending und nach 30 s auf failed.

App:
- chat_history_response-Merge faellt zusaetzlich auf text+timestamp-
  Heuristik im 5-Min-Fenster zurueck wenn die Server-Bubble keine
  clientMsgId hat → lokale Kopie wird verworfen, kein Doppelpost
- messagesRef + dispatchWithAck prueft vor Send/Retry ob die Bubble
  bereits delivered ist → kein verspaetetes failed mehr
- ARIA-Reply cleart ALLE laufenden ACK-Timer (Bridge hat unsere
  Messages ja offensichtlich verarbeitet)

Docs:
- issue.md: neuer Block 'Chat-Stabilitaet' mit den drei Etappen +
  beiden Race-Fixes; AsyncStorage-Race-Punkt aus 'Offen' abgehakt
- README.md: Chat-Such-Zeile aktualisiert (highlight statt filter),
  Jump-to-Bottom + Delivery-Status-Bubbles dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:46:58 +02:00
duffyduck c224562423 release: bump version to 0.1.4.5 2026-05-14 23:38:45 +02:00
duffyduck 5c07aef526 fix(chat): Offline-Bubble verschwand nach Reconnect — clientMsgId-Dedup
Race-Bug nach Etappe 3: Beim Reconnect schickt die App parallel
chat_history_request und (via flushQueuedMessages) die offline gestaute
Nachricht. Die history_response kam an bevor die Bridge die Bubble in
chat_backup.jsonl geschrieben hatte → Server-Liste ohne unsere Bubble →
Merge ersetzte den lokalen Stand → Bubble weg (im Diagnostic war sie
gleich danach drin).

Bridge: _append_chat_backup nimmt clientMsgId mit auf. send_to_core
reicht sie als kwarg durch (chat- und audio-Pfad).

App: chat_history_response-Merge dedupt per clientMsgId. Lokale User-
Bubbles deren clientMsgId der Server noch nicht kennt bleiben erhalten
(localOnly-Filter erweitert). Server-User-Bubbles mit clientMsgId
kriegen deliveryStatus='delivered' damit das ✓✓ auch nach Reload sichtbar
bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:14:11 +02:00
duffyduck d54d37061f release: bump version to 0.1.4.4 2026-05-14 23:09:08 +02:00
duffyduck a6afec0e11 release: bump version to 0.1.4.3 2026-05-14 22:59:25 +02:00
duffyduck 205112021b fix(chat): Such-Scroll + Doppel-Send-Hang + Delivery-Handshake
Drei Etappen Chat-Fixes:

Etappe 1 — Such-Scroll permanent springen weg:
- invertedMessages raus aus dem useEffect-Deps; neue ARIA-Nachrichten triggern den Scroll-Effect nicht mehr. Aktueller Snapshot via Ref.
- onScrollToIndexFailed: statt 3 cascading Retries (120/320/600ms) nur noch EINE Retry nach 300ms. Cascading-Retries waren der Endlos-Cascade-Bug (jeder Failed-Retry triggerte 3 weitere).

Etappe 2 — AsyncStorage-Race + Stuck-Thinking:
- Init-Load merged statt overwrite — Nachrichten die zwischen Mount und Load-Done reinkommen werden nicht mehr verschluckt.
- Stuck-Thinking-Watchdog: 180s ohne agent_activity-Update → Auto-Reset auf idle + Timeout-Bubble. Gegen "App haengt auf 'ARIA denkt'".

Etappe 3 — Delivery-Handshake (WhatsApp-Style):
- Pro User-Bubble: clientMsgId + deliveryStatus (queued/sending/sent/delivered/failed).
- Offline-Queue: Send waehrend disconnected → 'queued' → flush bei Reconnect.
- Bridge sendet chat_ack zurueck → Bubble auf 'sent' (✓).
- ARIA-Reply → alle vorigen User-Bubbles 'delivered' (✓✓).
- ACK-Timeout 30s, bis zu 3 Retries, danach 'failed' (rotes Tap-fuer-Retry).
- Bridge: LRU-Idempotenz (200 cmids) verhindert Doppelte beim Retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:55:44 +02:00
12 changed files with 1263 additions and 80 deletions
+3 -1
View File
@@ -362,7 +362,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
- **🗂️ Notizen-Inbox + Memory-Editor**: Neben der Lupe oeffnet `🗂️` ein Vollbild-Modal mit allen Memory/Trigger/Skill-Spezial-Bubbles aus dem Chat plus dem vollen DB-Browser. Tap auf eine Memory oeffnet ein **Detail/Edit-Modal**: Felder editieren, Anhaenge hoch-/runterladen + loeschen, Memory komplett loeschen. Identischer Editor auch in Settings → 🧠 Gedaechtnis. Spezial-Bubbles werden aus dem Chat-Stream gefiltert (keine ewig-unten-haengenden Notiz-Bubbles mehr)
- **Bubble-Header dynamic**: „ARIA hat etwas gemerkt" / „Notiz geaendert" (gelb) / „Notiz geloescht" (rot) — je nach action im memory_saved-Event
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10402
versionName "0.1.4.2"
versionCode 10409
versionName "0.1.4.9"
// 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.4.2",
"version": "0.1.4.9",
"private": true,
"scripts": {
"android": "react-native run-android",
+584 -48
View File
@@ -114,13 +114,36 @@ interface ChatMessage {
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
* wenn das chat_backup-Event vom Bridge zurueck kommt. */
backupTs?: number;
/** Client-seitige Eindeutigs-ID fuer Delivery-Tracking (offline-Queue,
* ACK von Bridge, Idempotenz bei Retry). Wird beim Senden generiert und
* durch die Bridge zurueck-gespiegelt. */
clientMsgId?: string;
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
sendAttempts?: number;
}
/** Ein Eintrag im Gedanken-Stream — chronologisches Log dessen was ARIA
* intern macht (Brain-`agent_activity`-Events). Bleibt zwischen Denk-
* Phasen stehen, wird in AsyncStorage persistiert. */
interface ThoughtEntry {
ts: number;
/** Roh-Activity vom Brain: thinking, tool, assistant, idle (= ✓ fertig). */
activity: string;
/** Bei activity='tool' der Tool-Name, sonst leer. */
tool?: string;
}
// --- Konstanten ---
const CHAT_STORAGE_KEY = 'aria_chat_messages';
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
const MAX_STORED_MESSAGES = 500;
const MAX_MEMORY_MESSAGES = 500;
const MAX_THOUGHTS = 500;
// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
// im Gespraechsmodus bei sehr vielen Nachrichten.
@@ -242,6 +265,14 @@ const ChatScreen: React.FC = () => {
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: ''});
// Gedanken-Stream: chronologisches Log dessen was ARIA intern macht.
// Wird aus agent_activity-Events gefuettert und in AsyncStorage persistiert.
const [thoughts, setThoughts] = useState<ThoughtEntry[]>([]);
const [thoughtsVisible, setThoughtsVisible] = useState(false);
// Spiegel der letzten Activity in einer Ref — verhindert dass aufeinander-
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
const lastThoughtKeyRef = useRef<string>('');
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
@@ -260,6 +291,20 @@ const ChatScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0);
// Spiegel der messages-Liste in einer Ref — Closures (z.B. dispatchWithAck-
// Retry) brauchen Zugriff auf den aktuellen Status einer Bubble.
const messagesRef = useRef<ChatMessage[]>([]);
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
// verloren ist oder das Brain abgestuerzt — Timeout-Bubble + Reset.
const stuckWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearStuckWatchdog = () => {
if (stuckWatchdog.current) {
clearTimeout(stuckWatchdog.current);
stuckWatchdog.current = null;
}
};
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
// file_response wird die Datei nach dem Speichern direkt mit dem System-
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
@@ -271,6 +316,121 @@ const ChatScreen: React.FC = () => {
return `msg_${Date.now()}_${messageIdCounter.current}`;
};
// Eindeutige clientMsgId fuer Delivery-Tracking (Bridge-Echo, Retry,
// Idempotenz). Format: cmsg_<ms>_<rand> — eindeutig genug fuer eine
// 100er-Dedup-Window auf der Bridge.
const nextClientMsgId = (): string =>
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
// 60s — grosszuegiger als 30s, weil langsame Brain-Calls (Multi-Tool) sonst
// 90s × 3 Retries lang die User-Bubble auf ⏳ stehen lassen wuerden. Der
// wichtige Pfad ist sowieso: agent_activity = thinking → markiert die
// Bubble sofort als 'sent' (siehe handler). Das hier ist Fallback wenn
// weder ACK noch agent_activity ankommt.
const ACK_TIMEOUT_MS = 60_000;
// Wie oft re-tryen wir bevor wir "failed" anzeigen.
const MAX_SEND_ATTEMPTS = 3;
// Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
const ackTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearAckTimer = (cmid: string) => {
const t = ackTimers.current.get(cmid);
if (t) {
clearTimeout(t);
ackTimers.current.delete(cmid);
}
};
// Pending-Payloads pro clientMsgId — wir brauchen sie fuer Retry nach
// ACK-Timeout oder nach Reconnect (offline-Queue). Liegt in einer Ref
// damit der Inhalt Closures ueberlebt.
const pendingPayloads = useRef<Map<string, { type: 'chat' | 'audio'; payload: Record<string, unknown> }>>(new Map());
// ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade)
// die sonst auf einen veralteten Wert zugreifen wuerden.
const connectionStateRef = useRef<ConnectionState>('disconnected');
// Status einer Bubble per clientMsgId aendern (Helper)
const updateMessageStatus = useCallback(
(cmid: string, patch: Partial<Pick<ChatMessage, 'deliveryStatus' | 'sendAttempts'>>) => {
setMessages(prev => prev.map(m => (m.clientMsgId === cmid ? { ...m, ...patch } : m)));
},
[],
);
// Sende eine 'chat'- oder 'audio'-Nachricht an die Bridge mit ACK-Tracking.
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
// - Wenn die Bubble inzwischen 'delivered' ist (z.B. ARIA hat geantwortet
// bevor das ACK durchkam) → komplett abbrechen, keinen Retry mehr.
const dispatchWithAck = useCallback(
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
// Schutz: wenn die Bubble inzwischen delivered ist, Retry-Loop stoppen
// (kann bei verspaeteten ACKs oder manuellem Retry passieren wenn ARIA
// schon laengst geantwortet hat).
const current = messagesRef.current.find(m => m.clientMsgId === cmid);
if (current?.deliveryStatus === 'delivered') {
clearAckTimer(cmid);
pendingPayloads.current.delete(cmid);
return;
}
pendingPayloads.current.set(cmid, { type, payload });
const online = connectionStateRef.current === 'connected';
if (!online) {
updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
return;
}
// RVS.send mit clientMsgId — Bridge spiegelt das im chat_ack zurueck
rvs.send(type, { ...payload, clientMsgId: cmid });
updateMessageStatus(cmid, { deliveryStatus: 'sending', sendAttempts: attempt });
clearAckTimer(cmid);
ackTimers.current.set(
cmid,
setTimeout(() => {
ackTimers.current.delete(cmid);
// Vor dem Retry erneut pruefen ob die Bubble nicht inzwischen
// delivered wurde — sonst spawnen wir endlose Retries.
const fresh = messagesRef.current.find(m => m.clientMsgId === cmid);
if (fresh?.deliveryStatus === 'delivered') {
pendingPayloads.current.delete(cmid);
return;
}
if (attempt >= MAX_SEND_ATTEMPTS) {
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
} else {
console.warn('[Chat] kein ACK fuer %s — Retry #%d', cmid, attempt + 1);
dispatchWithAck(cmid, type, payload, attempt + 1);
}
}, ACK_TIMEOUT_MS),
);
},
[updateMessageStatus],
);
// Alle 'queued'-Nachrichten beim Reconnect rausschicken
const flushQueuedMessages = useCallback(() => {
setMessages(prev => {
for (const m of prev) {
if (m.deliveryStatus !== 'queued' || !m.clientMsgId) continue;
const pending = pendingPayloads.current.get(m.clientMsgId);
if (!pending) continue;
// Versuchszaehler beibehalten (oder mit 1 starten falls leer)
dispatchWithAck(m.clientMsgId, pending.type, pending.payload, m.sendAttempts || 1);
}
return prev;
});
}, [dispatchWithAck]);
// Manueller Retry nach 'failed' (tap auf das ⚠️-Icon)
const retryFailedMessage = useCallback((cmid: string) => {
const pending = pendingPayloads.current.get(cmid);
if (!pending) return;
dispatchWithAck(cmid, pending.type, pending.payload, 1);
}, [dispatchWithAck]);
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
// sofort greift, ohne Context- oder Event-System)
useEffect(() => {
@@ -376,12 +536,24 @@ const ChatScreen: React.FC = () => {
const parsed: ChatMessage[] = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
console.log('[Chat] ${parsed.length} Nachrichten geladen');
setMessages(parsed);
// MERGE statt Overwrite: zwischen Mount und Load-Done koennen
// bereits Nachrichten ankommen (User schreibt sofort, WS-Events
// kommen vor Load-Ende). Vorher hat setMessages(parsed) diese
// ueberschrieben → "Nachricht weg ohne Spur". Jetzt mergen wir
// per id; lokal-gerade-hinzugefuegte schlagen Gespeichertes
// (die sind frischer).
setMessages(prev => {
if (prev.length === 0) return parsed;
const byId = new Map<string, ChatMessage>();
for (const m of parsed) byId.set(m.id, m);
for (const m of prev) byId.set(m.id, m);
return [...byId.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
});
const maxId = parsed.reduce((max, msg) => {
const num = parseInt(msg.id.split('_').pop() || '0', 10);
return num > max ? num : max;
}, 0);
messageIdCounter.current = maxId;
messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
}
}
} catch (err) {
@@ -419,6 +591,22 @@ const ChatScreen: React.FC = () => {
// RVS-Nachrichten abonnieren
useEffect(() => {
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
// chat_ack: Bridge bestaetigt Empfang einer chat/audio-Nachricht.
// Wir markieren die Bubble als 'sent' (✓) und stoppen den ACK-Timer.
if (message.type === ('chat_ack' as any)) {
const cmid = (message.payload as any).clientMsgId as string | undefined;
if (cmid) {
clearAckTimer(cmid);
pendingPayloads.current.delete(cmid);
setMessages(prev => prev.map(m =>
m.clientMsgId === cmid && m.deliveryStatus !== 'delivered'
? { ...m, deliveryStatus: 'sent' }
: m
));
}
return;
}
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
if (message.type === 'file_saved') {
const serverPath = (message.payload.serverPath as string) || '';
@@ -474,6 +662,10 @@ const ChatScreen: React.FC = () => {
mimeType: f.mimeType || '',
serverPath: f.serverPath || '',
})) as Attachment[];
// clientMsgId weiterreichen — Bridge spiegelt sie im chat_backup,
// damit wir lokale Bubbles per ID dedupen koennen statt nur per
// Text/Timestamp-Heuristik.
const cmid = typeof m.clientMsgId === 'string' ? m.clientMsgId : undefined;
return {
id: nextId(),
sender: role as 'user' | 'aria',
@@ -481,20 +673,48 @@ const ChatScreen: React.FC = () => {
timestamp: m.ts || Date.now(),
attachments: attachments.length ? attachments : undefined,
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
};
});
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
setMessages(prev => {
// ClientMsgIds die der Server kennt — lokale Bubbles mit der
// gleichen ID werden durch die Server-Version ersetzt.
const serverCmids = new Set(
fromServer.map(s => s.clientMsgId).filter((x): x is string => !!x)
);
// Lokal-only Bubbles erkennen + behalten:
// - Skill-Created-Notifications (skillCreated gesetzt)
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
// gesetzt UND text leer/Placeholder)
const localOnly = prev.filter(m =>
m.skillCreated ||
m.triggerCreated ||
m.memorySaved ||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
// - User-Bubbles deren clientMsgId der Server noch nicht kennt:
// z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
// noch laeuft. ABER: wenn der Server eine textgleiche User-
// Bubble hat (egal mit welcher cmid oder ohne — z.B. wenn die
// Bubble aus einer Bridge-Version vor dem clientMsgId-Patch
// stammt oder wenn die ts kaputt sind), werten wir das als
// Treffer und verwerfen die lokale Kopie. Sonst Doppelpost:
// einmal als Server-Bubble (delivered) und einmal als lokale
// failed/queued mit Retry-Knopf.
const serverUserTexts = new Set(
fromServer.filter(s => s.sender === 'user').map(s => s.text || '')
);
const localOnly = prev.filter(m => {
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
// Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
// User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
// kaputt etc.) — wir verwerfen die lokale Kopie. Leerer Text
// (z.B. nur Anhang) faellt nicht in den Vergleich.
const text = m.text || '';
if (text && serverUserTexts.has(text)) return false;
return true;
}
return false;
});
// Server-Stand + lokal-only (chronologisch sortiert)
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
return capMessages(merged);
@@ -750,8 +970,25 @@ const ChatScreen: React.FC = () => {
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
};
return capMessages([...prev, ariaMsg]);
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...prev, ariaMsg]).map(m =>
m.sender === 'user'
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
? { ...m, deliveryStatus: 'delivered' }
: m
);
});
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog();
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
// nachtraeglich loslaufen und die Bubble auf 'failed' setzen.
for (const cmid of Array.from(ackTimers.current.keys())) {
clearAckTimer(cmid);
pendingPayloads.current.delete(cmid);
}
}
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
@@ -794,8 +1031,55 @@ const ChatScreen: React.FC = () => {
const activity = (message.payload.activity as string) || 'idle';
const tool = (message.payload.tool as string) || '';
setAgentActivity({ activity, tool });
// Implizite ACK-Bestaetigung: Brain hat angefangen zu arbeiten →
// unsere Nachricht ist offensichtlich angekommen, auch wenn das
// chat_ack aus irgendeinem Grund nicht durchkam. Alle laufenden
// ACK-Timer canceln + sending-Bubbles auf 'sent' setzen.
// Vermeidet das Symptom "Sanduhr bleibt + Timeout" bei langsamen
// Brain-Antworten (>90 s, also nach 3 ACK-Retries auf failed).
if (activity !== 'idle' && ackTimers.current.size > 0) {
for (const cmid of Array.from(ackTimers.current.keys())) {
clearAckTimer(cmid);
}
setMessages(prev => prev.map(m =>
m.sender === 'user' && m.deliveryStatus === 'sending'
? { ...m, deliveryStatus: 'sent' }
: m
));
}
// In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
// Events (z.B. zwei mal 'thinking' direkt hintereinander). Tool-
// Events NIE dedupen — wenn ARIA dreimal Bash hintereinander ruft,
// sollen alle drei sichtbar sein.
const key = `${activity}|${tool}`;
const isTool = activity === 'tool';
if (isTool || key !== lastThoughtKeyRef.current) {
lastThoughtKeyRef.current = key;
setThoughts(prev => {
const next = [...prev, { ts: Date.now(), activity, tool }];
return next.length > MAX_THOUGHTS ? next.slice(-MAX_THOUGHTS) : next;
});
}
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
// activity-Event), Timer neu starten. 21 Min ohne Update → Hang.
// Knapp ueber Brain-Timeout (20 Min) damit nur bei echten
// Verbindungsabbruechen / Brain-Crashes gefeuert wird, nicht waehrend
// legitimer langer Multi-Tool-Sessions die das Brain selbst kappt.
clearStuckWatchdog();
if (activity !== 'idle') {
stuckWatchdog.current = setTimeout(() => {
stuckWatchdog.current = null;
setAgentActivity({ activity: 'idle', tool: '' });
setMessages(prev => capMessages([...prev, {
id: nextId(),
sender: 'aria',
text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 21 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
timestamp: Date.now(),
}]));
}, 1_260_000);
}
}
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
@@ -839,6 +1123,7 @@ const ChatScreen: React.FC = () => {
const unsubState = rvs.onStateChange((state) => {
setConnectionState(state);
connectionStateRef.current = state;
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
@@ -846,11 +1131,26 @@ const ChatScreen: React.FC = () => {
// Nachrichten vom Server, oder leeres Array wenn Server leer.
if (state === 'connected') {
rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
// Offline-Queue flushen — alle 'queued'-Bubbles raussschicken
flushQueuedMessages();
} else if (state === 'disconnected') {
// ACK-Timer cancellen, betroffene Bubbles auf 'queued' zurueck
for (const [cmid, t] of ackTimers.current.entries()) {
clearTimeout(t);
ackTimers.current.delete(cmid);
setMessages(prev => prev.map(m =>
m.clientMsgId === cmid && m.deliveryStatus === 'sending'
? { ...m, deliveryStatus: 'queued' }
: m
));
}
}
});
// Initalen Status setzen
setConnectionState(rvs.getState());
const initialState = rvs.getState();
setConnectionState(initialState);
connectionStateRef.current = initialState;
return () => {
unsubMessage();
@@ -1027,6 +1327,40 @@ const ChatScreen: React.FC = () => {
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
}, [messages]);
// Gedanken-Stream beim Mount aus AsyncStorage laden
useEffect(() => {
AsyncStorage.getItem(THOUGHT_STORAGE_KEY)
.then(raw => {
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) setThoughts(parsed.slice(-MAX_THOUGHTS));
} catch {}
})
.catch(() => {});
}, []);
// Gedanken-Stream persistieren (debounced)
const thoughtSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (thoughts.length === 0) {
AsyncStorage.removeItem(THOUGHT_STORAGE_KEY).catch(() => {});
return;
}
if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current);
thoughtSaveTimer.current = setTimeout(() => {
AsyncStorage.setItem(
THOUGHT_STORAGE_KEY,
JSON.stringify(thoughts.slice(-MAX_THOUGHTS)),
).catch(() => {});
}, 500);
return () => { if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current); };
}, [thoughts]);
// messagesRef immer aktuell halten — wird von dispatchWithAck/Retry gelesen
// damit Retries auf den aktuellen deliveryStatus reagieren koennen.
useEffect(() => { messagesRef.current = messages; }, [messages]);
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
// Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
// NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
@@ -1052,30 +1386,60 @@ const ChatScreen: React.FC = () => {
setSearchIndex(0);
}, [searchQuery]);
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
// Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
// neue Nachrichten kommen waehrend Suche aktiv ist → invertedMessages
// aendert sich, soll aber nicht den Scroll erneut triggern).
const lastSearchScrollKey = useRef<string>('');
// Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearPendingScrollRetry = () => {
if (pendingScrollRetry.current) {
clearTimeout(pendingScrollRetry.current);
pendingScrollRetry.current = null;
}
};
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
// FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport →
// Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar, kein
// weiteres Hochscrollen noetig. Plus mehrere Retries da Layout bei
// langen Listen zeitversetzt fertig wird.
// Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar.
// WICHTIG: invertedMessages bewusst NICHT in den Deps — sonst feuert das
// Effekt bei jeder neuen ARIA-Nachricht erneut und scrollt amok.
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
const invertedMessagesRef = useRef(invertedMessages);
invertedMessagesRef.current = invertedMessages;
useEffect(() => {
if (!searchMatchIds.length) return;
if (!searchMatchIds.length) {
lastSearchScrollKey.current = '';
clearPendingScrollRetry();
return;
}
const id = searchMatchIds[searchIndex];
if (!id) return;
const idx = invertedMessages.findIndex(m => m.id === id);
// Eindeutiger Schluessel pro Treffer-Stop — verhindert dass identische
// Re-Renders erneut scrollen.
const key = `${searchIndex}:${id}`;
if (lastSearchScrollKey.current === key) return;
lastSearchScrollKey.current = key;
// Neue Suche → alte Retries verwerfen
clearPendingScrollRetry();
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return;
const tryScroll = () => {
requestAnimationFrame(() => {
try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
} catch {
// wird von onScrollToIndexFailed nochmal versucht
// onScrollToIndexFailed-Handler uebernimmt den Fallback
}
};
// requestAnimationFrame fuer den ersten Versuch, dann setTimeout-Folge
// damit auch bei tiefen Indizes (viel ungelayoutete Items dazwischen)
// der Sprung am Ende sitzt.
requestAnimationFrame(tryScroll);
[180, 420, 800].forEach(d => setTimeout(tryScroll, d));
}, [searchIndex, searchMatchIds, invertedMessages]);
});
}, [searchIndex, searchMatchIds]);
// Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
useEffect(() => () => {
clearPendingScrollRetry();
clearStuckWatchdog();
}, []);
const activeSearchId = searchMatchIds[searchIndex] || '';
const gotoSearchPrev = () => {
@@ -1155,29 +1519,33 @@ const ChatScreen: React.FC = () => {
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
rvs.send('chat', {
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
const cancelRequest = useCallback(() => {
setAgentActivity({ activity: 'idle', tool: '' });
clearStuckWatchdog();
rvs.send('cancel_request' as any, {});
}, []);
@@ -1194,6 +1562,7 @@ const ChatScreen: React.FC = () => {
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
if (thinking) {
setAgentActivity({ activity: 'idle', tool: '' });
clearStuckWatchdog();
rvs.send('cancel_request' as any, {});
}
return true;
@@ -1206,16 +1575,20 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation();
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const cmid = nextClientMsgId();
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text: '🎙 Spracheingabe wird verarbeitet...',
timestamp: Date.now(),
audioRequestId,
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
};
setMessages(prev => capMessages([...prev, userMsg]));
rvs.send('audio', {
dispatchWithAck(cmid, 'audio', {
base64: result.base64,
durationMs: result.durationMs,
mimeType: result.mimeType,
@@ -1276,13 +1649,20 @@ const ChatScreen: React.FC = () => {
});
}
// Chat-Nachricht mit allen Anhaengen
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
const cmid = messageText ? nextClientMsgId() : undefined;
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments,
...(cmid && {
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
}),
};
setMessages(prev => capMessages([...prev, userMsg]));
@@ -1316,9 +1696,11 @@ const ChatScreen: React.FC = () => {
});
}
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
if (messageText) {
rvs.send('chat', {
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
// adressieren.
if (messageText && cmid) {
dispatchWithAck(cmid, 'chat', {
text: messageText,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
@@ -1328,7 +1710,7 @@ const ChatScreen: React.FC = () => {
setPendingAttachments([]);
setInputText('');
}, [pendingAttachments, getCurrentLocation]);
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// --- Rendering ---
@@ -1595,7 +1977,31 @@ const ChatScreen: React.FC = () => {
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
</TouchableOpacity>
) : null}
<Text style={styles.timestamp}>{time}</Text>
<View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text>
{isUser && item.deliveryStatus ? (
item.deliveryStatus === 'failed' && item.clientMsgId ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)}
>
<Text style={styles.statusFailed}>{'⚠ tippen f. Retry'}</Text>
</TouchableOpacity>
) : (
<Text style={
item.deliveryStatus === 'queued' ? styles.statusQueued :
item.deliveryStatus === 'sending' ? styles.statusSending :
item.deliveryStatus === 'sent' ? styles.statusSent :
/* delivered */ styles.statusDelivered
}>
{item.deliveryStatus === 'queued' ? '⏱' :
item.deliveryStatus === 'sending' ? '⏳' :
item.deliveryStatus === 'sent' ? '✓' :
/* delivered */ '✓✓'}
</Text>
)
) : null}
</View>
</View>
);
};
@@ -1638,7 +2044,13 @@ const ChatScreen: React.FC = () => {
{connectionState === 'connected' ? 'Verbunden' :
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
</Text>
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<TouchableOpacity onPress={() => setThoughtsVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6, flexDirection: 'row', alignItems: 'center'}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 16}}>{'\uD83D\uDCAD'}</Text>
{thoughts.length > 0 ? (
<Text style={{color: '#8888AA', fontSize: 11, marginLeft: 3}}>{thoughts.length}</Text>
) : null}
</TouchableOpacity>
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
<Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
@@ -1739,19 +2151,18 @@ const ChatScreen: React.FC = () => {
}}
scrollEventThrottle={120}
onScrollToIndexFailed={(info) => {
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann mehrfach
// praezise nachsetzen — bei langem Chat braucht's manchmal mehrere
// Runden bis die Layouts gemessen sind.
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
// nach 300ms praezise nachzusetzen. Mehr Retries → Endlos-Cascade
// (jeder failed Retry triggert wieder den Handler → 3, 9, 27 ...
// Scrolls in der Pipeline = der "permanent springen"-Bug).
const offset = info.averageItemLength * info.index;
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
// viewPosition 0 = Item-Top oben am Viewport → Stefan landet am
// Text-Anfang der Bubble, nicht in der Mitte oder am Ende.
[120, 320, 600].forEach(delay => {
setTimeout(() => {
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
}, delay);
});
clearPendingScrollRetry();
pendingScrollRetry.current = setTimeout(() => {
pendingScrollRetry.current = null;
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
}, 300);
}}
keyExtractor={item => item.id}
renderItem={renderMessage}
@@ -1914,6 +2325,102 @@ const ChatScreen: React.FC = () => {
</ErrorBoundary>
) : null}
{/* Gedanken-Stream — chronologisches Log von ARIAs interner Aktivitaet.
Bottom-Sheet (slide-up), 60% Bildschirmhoehe. Mülltonne zum Leeren. */}
<Modal
visible={thoughtsVisible}
animationType="slide"
transparent
onRequestClose={() => setThoughtsVisible(false)}
>
<TouchableOpacity
style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}
activeOpacity={1}
onPress={() => setThoughtsVisible(false)}
>
<TouchableOpacity activeOpacity={1} style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}>
{/* Drag-Indicator */}
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
</View>
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>
{'💭'} Gedanken-Stream {thoughts.length > 0 ? `(${thoughts.length})` : ''}
</Text>
{thoughts.length > 0 ? (
<TouchableOpacity
onPress={() => {
Alert.alert('Gedanken-Stream leeren?', `Alle ${thoughts.length} Eintraege werden geloescht.`, [
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Leeren', style: 'destructive', onPress: () => {
setThoughts([]);
lastThoughtKeyRef.current = '';
} },
]);
}}
hitSlop={{top:8,bottom:8,left:8,right:8}}
style={{paddingHorizontal:8}}
>
<Text style={{fontSize:18}}>{'🗑'}</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity onPress={() => setThoughtsVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
<Text style={{color:'#8888AA', fontSize:24}}>×</Text>
</TouchableOpacity>
</View>
{thoughts.length === 0 ? (
<View style={{flex:1, alignItems:'center', justifyContent:'center', padding:24}}>
<Text style={{color:'#555570', fontSize:13, fontStyle:'italic', textAlign:'center'}}>
Noch keine Gedanken aufgezeichnet.{'\n'}Sobald ARIA was tut, taucht's hier auf.
</Text>
</View>
) : (
<FlatList
data={thoughts}
keyExtractor={(_, i) => `t_${i}`}
contentContainerStyle={{paddingVertical:8}}
renderItem={({ item, index }) => {
const prev = index > 0 ? thoughts[index - 1] : null;
// Lange Pause? → Trenn-Linie mit Minuten-Hint
const gapMin = prev ? Math.floor((item.ts - prev.ts) / 60000) : 0;
const showGap = gapMin >= 1;
const time = new Date(item.ts).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
const icon =
item.activity === 'idle' ? '' :
item.activity === 'tool' ? '🔧' :
item.activity === 'assistant' ? '' :
item.activity === 'thinking' ? '💭' : '';
const label =
item.activity === 'idle' ? 'fertig' :
item.activity === 'tool' ? (item.tool || 'tool') :
item.activity === 'assistant' ? 'schreibt' :
item.activity === 'thinking' ? 'denkt' : item.activity;
const isIdle = item.activity === 'idle';
return (
<View>
{showGap ? (
<View style={{flexDirection:'row', alignItems:'center', paddingHorizontal:16, paddingVertical:6}}>
<View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
<Text style={{color:'#555570', fontSize:10, paddingHorizontal:8}}>
{gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`}
</Text>
<View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
</View>
) : null}
<View style={{flexDirection:'row', paddingHorizontal:16, paddingVertical:5}}>
<Text style={{color:'#555570', fontSize:11, width:78}}>{time}</Text>
<Text style={{fontSize:13, width:24}}>{icon}</Text>
<Text style={{color: isIdle ? '#34C759' : '#E0E0F0', fontSize:13, flex:1}}>{label}</Text>
</View>
</View>
);
}}
/>
)}
</TouchableOpacity>
</TouchableOpacity>
</Modal>
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
@@ -2174,6 +2681,35 @@ const styles = StyleSheet.create({
marginTop: 4,
alignSelf: 'flex-end',
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-end',
gap: 6,
marginTop: 4,
},
statusQueued: {
color: '#FFD60A', // Gelb — wartet auf Verbindung
fontSize: 11,
},
statusSending: {
color: 'rgba(255,255,255,0.5)',
fontSize: 11,
},
statusSent: {
color: 'rgba(255,255,255,0.6)',
fontSize: 12,
},
statusDelivered: {
color: '#34C759', // Gruen — Brain hat geantwortet
fontSize: 12,
fontWeight: '700',
},
statusFailed: {
color: '#FF3B30',
fontSize: 11,
fontWeight: '700',
},
emptyContainer: {
flex: 1,
alignItems: 'center',
+11 -10
View File
@@ -164,15 +164,17 @@ def build_skills_section(skills: List[dict]) -> str:
"static-ffmpeg, beautifulsoup4, …). Falls etwas WIRKLICH nur via apt geht: "
"Stefan fragen ob es ins Brain-Dockerfile soll.")
lines.append("")
lines.append("**Harte Regel — IMMER Skill anlegen wenn:** die Loesung erfordert eine "
"pip-Library. Begruendung: Brain-Container hat keinen persistenten State "
"ausser /data/skills/. Ohne Skill wuerde der Install bei jedem "
"Container-Restart wiederholt.")
lines.append("**Goldene Regel: NIE ungefragt Skills anlegen.** Selbst wenn die Aufgabe "
"eine pip-Library braucht — erst die Aufgabe loesen (mit Bash, `pip install` "
"im Brain ist ok, oder Workaround), und nur wenn Stefan EXPLIZIT sagt "
"'mach daraus einen Skill' / 'leg den als Skill an' / 'dafuer einen Skill' "
"rufst du `skill_create` auf. Begruendung: Skill-Setup (venv + pip install) "
"blockt das Brain bis zu 12 Minuten. Ein unaufgefordert angelegter Skill "
"macht ARIA stumm und nervt Stefan jedes Mal.")
lines.append("")
lines.append("**Sonst — Skill nur wenn alle vier zutreffen:**")
lines.append("**Wenn Stefan einen Skill explizit moechte, pruef:**")
lines.append("")
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt. "
"Einmal-Faelle (\"wie spaet ist es jetzt\") kein Skill.")
lines.append("1. **Wiederkehrend** — die Aufgabe wird realistisch nochmal gestellt.")
lines.append("2. **Nicht-trivial** — mehrere Schritte. Ein einzelner Shell-Befehl "
"(`date`, `hostname`, `ls`) ist KEIN Skill — das macht Bash direkt.")
lines.append("3. **Parametrisierbar** — der Skill nimmt Eingaben (URL, Datei, Suchbegriff) "
@@ -180,9 +182,8 @@ def build_skills_section(skills: List[dict]) -> str:
lines.append("4. **Wiederverwendbar als ganzes** — Stefan wuerde es zukuenftig per Name "
"ansprechen (\"mach mir den YouTube zu MP3\") statt jedes Mal zu erklaeren.")
lines.append("")
lines.append("Wenn nichts installiert werden muss UND nicht alle vier zutreffen: einfach "
"die Aufgabe loesen ohne Skill anzulegen. Stefan kann jederzeit sagen "
"'bau daraus einen Skill'.")
lines.append("Wenn auch nur EINE der vier nicht zutrifft: hoeflich nachfragen ob er "
"wirklich einen permanenten Skill will oder die Aufgabe einmalig reicht.")
return "\n".join(lines)
+1 -1
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "300"))
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "1200"))
def _read_model_from_runtime() -> str:
+118 -16
View File
@@ -25,6 +25,7 @@ import time
import sys
import tempfile
import uuid
from collections import OrderedDict
from pathlib import Path
from typing import Optional
@@ -475,6 +476,13 @@ class ARIABridge:
self.current_mode = self._load_persisted_mode()
self.running = False
# Idempotenz: zuletzt gesehene clientMsgIds (App-seitig generiert).
# Beim Reconnect/Retry sendet die App dieselbe ID nochmal — wir
# antworten erneut mit ACK aber leiten NICHT doppelt an Brain weiter.
# OrderedDict als FIFO mit Capping (Insertion-Order).
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
self._SEEN_CLIENT_MSG_LIMIT = 200
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
self.tts_enabled = True
self.xtts_voice = ""
@@ -989,8 +997,13 @@ class ARIABridge:
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
Wird von Diagnostic + App als History-Quelle gelesen.
entry braucht mindestens {role, text}; ts wird ergaenzt.
Returns den ts (auch fuer Bubble-Loeschen-Tracking)."""
ts = int(asyncio.get_event_loop().time() * 1000)
Returns den ts (auch fuer Bubble-Loeschen-Tracking).
WICHTIG: ts ist UNIX-ms (time.time()*1000), NICHT loop-time.
Loop-time ist Container-monotonic — bei jedem Restart wieder 0.
Das brach die App-History-Sortierung weil App-side Date.now()
(echtes UNIX-ms) mit Bridge-Container-Uptime gemischt wurde."""
ts = int(time.time() * 1000)
try:
line = {"ts": ts}
line.update(entry)
@@ -1308,10 +1321,12 @@ class ARIABridge:
self._pending_files_flush_task = None
text = self._build_pending_files_message(user_text)
self._pending_files = []
await self.send_to_core(text, source="app-file+chat")
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
# fertig ist (siehe chat-handler oben).
asyncio.create_task(self.send_to_core(text, source="app-file+chat"))
return True
async def send_to_core(self, text: str, source: str = "bridge") -> None:
async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None:
"""Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort.
Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir
@@ -1325,8 +1340,13 @@ class ARIABridge:
logger.info("[brain] chat ← %s '%s'", source, text[:80])
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
# / Diagnostic-Reload als History-Quelle gelesen.
self._append_chat_backup({"role": "user", "text": text, "source": source})
# / 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).
entry: dict = {"role": "user", "text": text, "source": source}
if client_msg_id:
entry["clientMsgId"] = client_msg_id
self._append_chat_backup(entry)
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
@@ -1338,8 +1358,10 @@ class ARIABridge:
url, data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
# Cold-Start kann lange dauern, 5min Timeout
with urllib.request.urlopen(req, timeout=300) as resp:
# 20 Min Timeout — lange Multi-Tool-Workflows (Karten,
# PDFs, viele curl-Calls) brauchen das. 5 Min waren chronisch
# zu knapp und haben ARIA mitten in der Arbeit gekappt.
with urllib.request.urlopen(req, timeout=1200) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as exc:
return None, str(exc)
@@ -1530,6 +1552,36 @@ class ARIABridge:
except Exception:
break
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
App nach Timeout retryen — gegen Verlust bei Netz-Hicksern.
"""
if not client_msg_id:
return
await self._send_to_rvs({
"type": "chat_ack",
"payload": {"clientMsgId": client_msg_id},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
def _is_duplicate_client_msg(self, client_msg_id: Optional[str]) -> bool:
"""Prueft ob wir diese clientMsgId schon verarbeitet haben.
Wenn ja → True (Caller soll ACK senden aber NICHT an Brain forwarden).
Wenn nein → in den Seen-Cache aufnehmen + False zurueck.
"""
if not client_msg_id:
return False
if client_msg_id in self._seen_client_msg_ids:
logger.info("[rvs] Idempotenz: cmid=%s bereits verarbeitet, ignoriere",
client_msg_id)
return True
self._seen_client_msg_ids[client_msg_id] = time.time()
# Capping: aelteste Eintraege rauswerfen
while len(self._seen_client_msg_ids) > self._SEEN_CLIENT_MSG_LIMIT:
self._seen_client_msg_ids.popitem(last=False)
return False
async def _handle_rvs_message(self, raw_message: str) -> None:
"""Verarbeitet Nachrichten von der App (via RVS).
@@ -1554,6 +1606,13 @@ class ARIABridge:
sender = payload.get("sender", "")
if sender in ("aria", "stt"):
return
# Delivery-ACK: immer zurueckschicken (auch bei Idempotenz-Hit),
# damit die App den Status auf 'sent' setzen kann. Idempotenz-
# Check VERHINDERT aber die Doppel-Weiterleitung an Brain.
client_msg_id = payload.get("clientMsgId") or None
await self._send_chat_ack(client_msg_id)
if self._is_duplicate_client_msg(client_msg_id):
return
text = payload.get("text", "")
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
@@ -1589,7 +1648,16 @@ class ARIABridge:
" [BARGE-IN]" if interrupted else "",
" [GPS]" if location else "",
text[:80])
await self.send_to_core(core_text, source="app" + (" [barge-in]" if interrupted else ""))
# KEIN await: send_to_core kann 20 Min dauern. Wenn wir
# hier awaiten, blockt der `async for raw_message in ws`-
# Loop solange → RVS-Server droppt uns nach ~4 Min idle.
# Als Task: Brain laeuft im Hintergrund, RVS-recv bleibt
# bedienbar, Pings werden beantwortet, Verbindung lebt.
asyncio.create_task(self.send_to_core(
core_text,
source="app" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id,
))
return
if msg_type == "cancel_request":
@@ -1765,7 +1833,8 @@ class ARIABridge:
if not file_b64:
text = f"Stefan hat eine Datei gesendet ({file_name}, {file_type}) aber die Daten sind leer angekommen."
await self.send_to_core(text, source="app-file")
# create_task statt await — RVS-recv darf nicht blocken
asyncio.create_task(self.send_to_core(text, source="app-file"))
return
if file_type.startswith("image/"):
@@ -2153,6 +2222,12 @@ class ARIABridge:
elif msg_type == "audio":
# Audio von der App → decodieren → STT → an aria-core
# Delivery-ACK + Idempotenz wie bei chat — App nutzt die ACKs
# auch fuer Sprach-Bubbles (Status auf der Bubble: ✓ sent).
client_msg_id = payload.get("clientMsgId") or None
await self._send_chat_ack(client_msg_id)
if self._is_duplicate_client_msg(client_msg_id):
return
audio_b64 = payload.get("base64", "")
mime_type = payload.get("mimeType", "audio/mp4")
duration_ms = payload.get("durationMs", 0)
@@ -2183,7 +2258,8 @@ class ARIABridge:
" [GPS]" if location else "",
f" reqId={audio_request_id[:16]}" if audio_request_id else "")
asyncio.create_task(self._process_app_audio(
audio_b64, mime_type, interrupted, audio_request_id, location))
audio_b64, mime_type, interrupted, audio_request_id, location,
client_msg_id=client_msg_id))
elif msg_type == "stt_response":
# Antwort der whisper-bridge auf unseren stt_request
@@ -2242,7 +2318,8 @@ class ARIABridge:
async def _process_app_audio(self, audio_b64: str, mime_type: str,
interrupted: bool = False,
audio_request_id: str = "",
location: Optional[dict] = None) -> None:
location: Optional[dict] = None,
client_msg_id: Optional[str] = None) -> None:
"""App-Audio → STT → aria-core. Primaer via whisper-bridge (RVS), Fallback lokal.
interrupted=True wenn der User waehrend ARIA noch sprach/dachte aufgenommen hat
@@ -2298,7 +2375,9 @@ class ARIABridge:
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
await self.send_to_core(core_text,
source="app-voice" + (" [barge-in]" if interrupted else ""),
client_msg_id=client_msg_id)
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
@@ -2445,17 +2524,22 @@ class ARIABridge:
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
async def _emit_activity(self, activity: str, tool: str = "") -> None:
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
(nur 'idle' kommt immer durch)."""
(nur 'idle' kommt immer durch).
force=True: kein State-Dedup — wird vom Proxy-Tool-Hook genutzt
damit auch wiederholte gleiche Tool-Aufrufe (z.B. 3x Bash
hintereinander) im Gedanken-Stream als eigene Eintraege sichtbar
bleiben."""
if activity != "idle" and self._last_chat_final_at > 0:
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
if since_final < 3.0:
return
state = (activity, tool)
if state == self._last_activity_state:
if not force and state == self._last_activity_state:
return
self._last_activity_state = state
await self._send_to_rvs({
@@ -2603,6 +2687,24 @@ class ARIABridge:
self._handle_trigger_fired(reply, trigger_name, ttype, events)
)
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/agent-activity":
# Vom Proxy gefeuert bei jedem Claude-Code-tool_use-Event
# (Bash, Read, Edit, Grep, ...). Wir spiegeln das als
# RVS agent_activity an App+Diagnostic damit der Gedanken-
# Stream live mitlaufen kann.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
tool = (data.get("tool") or "").strip()
if not tool:
await _send_response(writer, 400, {"error": "tool erforderlich"})
return
# Force-emit (kein Dedup): User soll JEDEN Tool-Call sehen
# selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/delete-chat-message":
try:
data = json.loads(body.decode("utf-8", "ignore"))
+129
View File
@@ -301,6 +301,7 @@
<input type="checkbox" id="gps-debug-toggle" onchange="toggleGpsDebug()" style="margin-right:4px;vertical-align:middle;">
GPS-Position einblenden
</label>
<button class="btn secondary" onclick="openThoughtStream()" id="btn-thoughts" title="Gedanken-Stream — was ARIA intern tut" style="padding:4px 10px;font-size:11px;">&#x1F4AD; Gedanken <span id="thoughts-count" style="color:#8888AA;"></span></button>
<button class="btn secondary" onclick="toggleChatFullscreen()" id="btn-chat-fs" style="padding:4px 10px;font-size:11px;">Vollbild</button>
</div>
</div>
@@ -342,6 +343,22 @@
</div>
</div>
<!-- Gedanken-Stream Modal — chronologisches Log was ARIA intern tut.
Zentrales Modal (max 720px breit), Liste mit Auto-Scroll ans Ende
wenn neue Eintraege reinkommen. -->
<div id="thought-stream-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.7);z-index:1100;align-items:center;justify-content:center;padding:24px;" onclick="if(event.target===this) closeThoughtStream();">
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:12px;width:100%;max-width:720px;height:70vh;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;padding:14px;border-bottom:1px solid #1E1E2E;">
<h2 style="margin:0;color:#FFD60A;flex:1;font-size:16px;">&#x1F4AD; Gedanken-Stream <span id="thoughts-count-modal" style="color:#8888AA;font-weight:normal;"></span></h2>
<button class="btn secondary" onclick="clearThoughtStream()" id="btn-clear-thoughts" title="Stream leeren" style="padding:4px 10px;font-size:11px;color:#FF3B30;border-color:#FF3B30;margin-right:6px;">&#x1F5D1; Leeren</button>
<button class="btn secondary" onclick="closeThoughtStream()" style="padding:4px 12px;">Schliessen</button>
</div>
<div id="thought-stream-list" style="flex:1;overflow-y:auto;padding:8px 0;font-size:13px;font-family:monospace;">
<!-- gefuellt durch renderThoughtStream() -->
</div>
</div>
</div>
<!-- Sessions + alter Brain-Viewer entfernt — Memories laufen jetzt
komplett ueber den Gehirn-Tab gegen die Vector-DB im aria-brain. -->
@@ -2166,6 +2183,9 @@
}
function updateThinkingIndicator(msg) {
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
pushThought(msg.activity || '', msg.tool || '');
const indicators = [
document.getElementById('thinking-indicator'),
document.getElementById('thinking-indicator-fs'),
@@ -2202,6 +2222,114 @@
}, 120000);
}
// ── Gedanken-Stream ─────────────────────────────
// Chronologisches Log von agent_activity-Events. Wird in localStorage
// persistiert (ueberlebt Page-Reload), capped auf MAX_THOUGHTS.
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
const MAX_THOUGHTS = 500;
let thoughtStream = [];
let lastThoughtKey = '';
let _thoughtSaveTimer = null;
function loadThoughtStream() {
try {
const raw = localStorage.getItem(THOUGHT_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) thoughtStream = parsed.slice(-MAX_THOUGHTS);
} catch {}
updateThoughtsBadge();
}
function persistThoughtStream() {
if (_thoughtSaveTimer) clearTimeout(_thoughtSaveTimer);
_thoughtSaveTimer = setTimeout(() => {
try {
if (thoughtStream.length === 0) localStorage.removeItem(THOUGHT_STORAGE_KEY);
else localStorage.setItem(THOUGHT_STORAGE_KEY, JSON.stringify(thoughtStream.slice(-MAX_THOUGHTS)));
} catch {}
}, 500);
}
function pushThought(activity, tool) {
// Dedup gegen direkt aufeinanderfolgende identische Events. Tool-
// Events NIE dedupen — drei Bash-Calls in Folge sollen drei Eintraege
// ergeben, nicht einen.
const key = `${activity}|${tool || ''}`;
if (activity !== 'tool' && key === lastThoughtKey) return;
lastThoughtKey = key;
thoughtStream.push({ ts: Date.now(), activity, tool: tool || '' });
if (thoughtStream.length > MAX_THOUGHTS) thoughtStream = thoughtStream.slice(-MAX_THOUGHTS);
updateThoughtsBadge();
// Wenn das Modal offen ist: live nachrendern + ans Ende scrollen
const modal = document.getElementById('thought-stream-modal');
if (modal && modal.style.display !== 'none') renderThoughtStream(true);
persistThoughtStream();
}
function updateThoughtsBadge() {
const a = document.getElementById('thoughts-count');
if (a) a.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
const b = document.getElementById('thoughts-count-modal');
if (b) b.textContent = thoughtStream.length ? `(${thoughtStream.length})` : '';
}
function openThoughtStream() {
const modal = document.getElementById('thought-stream-modal');
if (!modal) return;
modal.style.display = 'flex';
renderThoughtStream(true);
}
function closeThoughtStream() {
const modal = document.getElementById('thought-stream-modal');
if (modal) modal.style.display = 'none';
}
function clearThoughtStream() {
if (thoughtStream.length === 0) return;
if (!confirm(`Gedanken-Stream leeren? ${thoughtStream.length} Eintraege werden geloescht.`)) return;
thoughtStream = [];
lastThoughtKey = '';
updateThoughtsBadge();
renderThoughtStream(false);
persistThoughtStream();
}
function _escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderThoughtStream(autoscroll) {
const list = document.getElementById('thought-stream-list');
if (!list) return;
if (thoughtStream.length === 0) {
list.innerHTML = '<div style="padding:24px;text-align:center;color:#555570;font-style:italic;">Noch keine Gedanken aufgezeichnet.<br>Sobald ARIA was tut, taucht\'s hier auf.</div>';
return;
}
const rows = [];
let prevTs = 0;
for (const t of thoughtStream) {
const gapMin = prevTs ? Math.floor((t.ts - prevTs) / 60000) : 0;
if (gapMin >= 1) {
const label = gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`;
rows.push(`<div style="display:flex;align-items:center;padding:6px 16px;gap:8px;"><div style="flex:1;height:1px;background:#1E1E2E;"></div><span style="color:#555570;font-size:10px;">${label}</span><div style="flex:1;height:1px;background:#1E1E2E;"></div></div>`);
}
prevTs = t.ts;
const d = new Date(t.ts);
const time = `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
let icon, label, color;
if (t.activity === 'idle') { icon = '✓'; label = 'fertig'; color = '#34C759'; }
else if (t.activity === 'tool') { icon = '🔧'; label = t.tool || 'tool'; color = '#E0E0F0'; }
else if (t.activity === 'assistant'){ icon = '✍️'; label = 'schreibt'; color = '#E0E0F0'; }
else if (t.activity === 'thinking'){ icon = '💭'; label = 'denkt'; color = '#E0E0F0'; }
else { icon = '•'; label = t.activity; color = '#E0E0F0'; }
rows.push(`<div style="display:flex;padding:4px 16px;align-items:baseline;"><span style="color:#555570;width:78px;font-size:11px;">${time}</span><span style="width:24px;">${icon}</span><span style="color:${color};flex:1;">${_escapeHtml(label)}</span></div>`);
}
list.innerHTML = rows.join('');
if (autoscroll) list.scrollTop = list.scrollHeight;
}
// ── XTTS Panel ─────────────────────────────
function renderVoiceList(voices) {
const box = document.getElementById('xtts-voice-list');
@@ -4696,6 +4824,7 @@
});
}
loadThoughtStream();
connectWS();
</script>
</body>
+2
View File
@@ -12,8 +12,10 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
claude-max-api"
volumes:
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
+10 -1
View File
@@ -341,10 +341,19 @@ Skills mit Tool-Use.
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
### Chat-Stabilitaet: Such-Scroll, Stuck-Watchdog, Delivery-Handshake
- [x] **Such-Scroll springt nicht mehr permanent**: `onScrollToIndexFailed` hatte 3 cascading `setTimeout`s (120/320/600 ms) — jeder failed Retry triggerte den Handler wieder → 3, 9, 27 Scrolls in der Pipeline. Plus `invertedMessages` war in den useEffect-Deps: jede neue ARIA-Nachricht re-triggerte den Such-Scroll. Fix: nur EIN Retry nach 300 ms, in einer Ref-getrackten Timer-Variable; bei neuem Such-Hit wird der pending Retry gecancelt. `invertedMessages`-Snapshot via Ref statt Dep
- [x] **Jump-to-Bottom-Button** rechts unten in der Chat-Liste — taucht ab ~250 px Scroll-Weg auf, scrollt zur neuesten Nachricht (bei inverted FlatList `scrollToOffset(0)`)
- [x] **AsyncStorage-Init-Race**: zwischen Mount und „Verlauf aus AsyncStorage geladen" konnte eine User-Nachricht oder ein WS-Event ankommen — `setMessages(parsed)` ueberschrieb's mit dem alten Stand und die frische Nachricht war spurlos weg. Fix: Merge per `id` (frischere `prev`-Eintraege schlagen Gespeichertes), sortiert nach `timestamp`. `messageIdCounter` wird nur noch erhoeht, nie zurueckgesetzt
- [x] **Stuck-Thinking-Watchdog**: „ARIA denkt..." blieb gelegentlich kleben (Brain-Crash, WS-Disconnect ohne idle-Event, Cancel mit Race). Fix: jeder `agent_activity != idle` armiert einen 180s-Timer; ohne neues Lebenszeichen geht's auto-idle + Bubble „⚠ Habe gerade keine Verbindung zurueck bekommen". Watchdog wird beim ARIA-Reply, beim Cancel/Barge-In und beim Screen-Unmount gecleart
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
## Offen
### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
### Architektur
+309
View File
@@ -0,0 +1,309 @@
/**
* ARIA-patched API Route Handlers
*
* Erweiterung der npm-Version von claude-max-api-proxy:
* - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read,
* Edit, Grep, …) wird ein HTTP-POST an die Bridge gefeuert
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
* der Brain-Call NICHT ab.
*
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
* (siehe docker-compose.yml proxy-Block).
*/
import { v4 as uuidv4 } from "uuid";
import http from "http";
import { ClaudeSubprocess } from "../subprocess/manager.js";
import { openaiToCli } from "../adapter/openai-to-cli.js";
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|| "http://aria-bridge:8090/internal/agent-activity";
/**
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
* keine Fehler nach oben. Logged Fehler still.
*/
function _emitToolEvent(toolName) {
if (!toolName) return;
try {
const u = new URL(TOOL_HOOK_URL);
const body = JSON.stringify({ tool: String(toolName) });
const req = http.request({
method: "POST",
hostname: u.hostname,
port: u.port || 80,
path: u.pathname,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
timeout: 2000,
}, (res) => { res.resume(); });
req.on("error", () => {});
req.on("timeout", () => req.destroy());
req.write(body);
req.end();
} catch (_) { /* niemals weiterwerfen */ }
}
/**
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
* kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
*/
function _attachToolHook(subprocess) {
subprocess.on("assistant", (message) => {
try {
const blocks = message?.message?.content || [];
for (const b of blocks) {
if (b && b.type === "tool_use" && b.name) {
_emitToolEvent(b.name);
}
}
} catch (_) { /* fail-open */ }
});
}
/**
* Handle POST /v1/chat/completions
*
* Main endpoint for chat requests, supports both streaming and non-streaming
*/
export async function handleChatCompletions(req, res) {
const requestId = uuidv4().replace(/-/g, "").slice(0, 24);
const body = req.body;
const stream = body.stream === true;
try {
// Validate request
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
res.status(400).json({
error: {
message: "messages is required and must be a non-empty array",
type: "invalid_request_error",
code: "invalid_messages",
},
});
return;
}
// Convert to CLI input format
const cliInput = openaiToCli(body);
const subprocess = new ClaudeSubprocess();
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
// Greift fuer beide Branches (stream + non-stream).
_attachToolHook(subprocess);
if (stream) {
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
}
else {
await handleNonStreamingResponse(res, subprocess, cliInput, requestId);
}
}
catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("[handleChatCompletions] Error:", message);
if (!res.headersSent) {
res.status(500).json({
error: {
message,
type: "server_error",
code: null,
},
});
}
}
}
/**
* Handle streaming response (SSE)
*
* IMPORTANT: The Express req.on("close") event fires when the request body
* is fully received, NOT when the client disconnects. For SSE connections,
* we use res.on("close") to detect actual client disconnection.
*/
async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) {
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Request-Id", requestId);
// CRITICAL: Flush headers immediately to establish SSE connection
// Without this, headers are buffered and client times out waiting
res.flushHeaders();
// Send initial comment to confirm connection is alive
res.write(":ok\n\n");
return new Promise((resolve, reject) => {
let isFirst = true;
let lastModel = "claude-sonnet-4";
let isComplete = false;
// Handle actual client disconnect (response stream closed)
res.on("close", () => {
if (!isComplete) {
// Client disconnected before response completed - kill subprocess
subprocess.kill();
}
resolve();
});
// Handle streaming content deltas
subprocess.on("content_delta", (event) => {
const text = event.event.delta?.text || "";
if (text && !res.writableEnded) {
const chunk = {
id: `chatcmpl-${requestId}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: lastModel,
choices: [{
index: 0,
delta: {
role: isFirst ? "assistant" : undefined,
content: text,
},
finish_reason: null,
}],
};
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
isFirst = false;
}
});
// Handle final assistant message (for model name)
subprocess.on("assistant", (message) => {
lastModel = message.message.model;
});
subprocess.on("result", (_result) => {
isComplete = true;
if (!res.writableEnded) {
// Send final done chunk with finish_reason
const doneChunk = createDoneChunk(requestId, lastModel);
res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}
resolve();
});
subprocess.on("error", (error) => {
console.error("[Streaming] Error:", error.message);
if (!res.writableEnded) {
res.write(`data: ${JSON.stringify({
error: { message: error.message, type: "server_error", code: null },
})}\n\n`);
res.end();
}
resolve();
});
subprocess.on("close", (code) => {
// Subprocess exited - ensure response is closed
if (!res.writableEnded) {
if (code !== 0 && !isComplete) {
// Abnormal exit without result - send error
res.write(`data: ${JSON.stringify({
error: { message: `Process exited with code ${code}`, type: "server_error", code: null },
})}\n\n`);
}
res.write("data: [DONE]\n\n");
res.end();
}
resolve();
});
// Start the subprocess
subprocess.start(cliInput.prompt, {
model: cliInput.model,
sessionId: cliInput.sessionId,
}).catch((err) => {
console.error("[Streaming] Subprocess start error:", err);
reject(err);
});
});
}
/**
* Handle non-streaming response
*/
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
return new Promise((resolve) => {
let finalResult = null;
subprocess.on("result", (result) => {
finalResult = result;
});
subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message);
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
resolve();
});
subprocess.on("close", (code) => {
if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId));
}
else if (!res.headersSent) {
res.status(500).json({
error: {
message: `Claude CLI exited with code ${code} without response`,
type: "server_error",
code: null,
},
});
}
resolve();
});
// Start the subprocess
subprocess
.start(cliInput.prompt, {
model: cliInput.model,
sessionId: cliInput.sessionId,
})
.catch((error) => {
res.status(500).json({
error: {
message: error.message,
type: "server_error",
code: null,
},
});
resolve();
});
});
}
/**
* Handle GET /v1/models
*
* Returns available models
*/
export function handleModels(_req, res) {
res.json({
object: "list",
data: [
{
id: "claude-opus-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-sonnet-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-haiku-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
],
});
}
/**
* Handle GET /health
*
* Health check endpoint
*/
export function handleHealth(_req, res) {
res.json({
status: "ok",
provider: "claude-code-cli",
timestamp: new Date().toISOString(),
});
}
//# sourceMappingURL=routes.js.map
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen.
Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`,
was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side
`Date.now()` (echtes UNIX-ms) → falsche Sortierung in der App-History.
Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime,
decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten
(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn).
Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix)
werden unveraendert gelassen.
Idempotent: zweimal laufen lassen ist sicher — beim zweiten Mal sind alle ts
schon UNIX-ms und werden nicht angefasst.
Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace.
"""
import json
import os
import shutil
import sys
import time
from pathlib import Path
UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX
GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime
def migrate(path: Path) -> None:
if not path.exists():
print(f"Datei nicht da: {path}")
sys.exit(1)
raw = path.read_text(encoding="utf-8").splitlines()
entries = []
for raw_line in raw:
s = raw_line.strip()
if not s:
continue
try:
entries.append(json.loads(s))
except Exception as e:
print(f" ueberspringe kaputte Zeile: {e}")
continue
if not entries:
print("Datei leer")
return
file_mtime_ms = int(os.path.getmtime(path) * 1000)
n = len(entries)
fixed = 0
# Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer
# Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem
# UNIX-ms bleiben unangetastet.
for i, entry in enumerate(entries):
ts = entry.get("ts", 0)
if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD:
# Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime
new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000
entry["ts"] = new_ts
fixed += 1
if fixed == 0:
print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)")
return
# Backup
bak = path.with_suffix(path.suffix + ".bak")
shutil.copy2(path, bak)
print(f"Backup: {bak}")
# Atomic rewrite
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
for entry in entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
tmp.replace(path)
print(f"Migration fertig: {fixed}/{n} ts umgeschrieben")
print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}")
print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}")
if __name__ == "__main__":
default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl")
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
migrate(path)