Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6afec0e11 | |||
| 205112021b | |||
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd | |||
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed | |||
| 3cf6308b79 | |||
| 7e5a4da659 | |||
| d27fcaf342 | |||
| 5b28a065c0 | |||
| e74e1eaf70 |
@@ -200,7 +200,7 @@ Die Diagnostic-UI hat sechs Top-Tabs:
|
|||||||
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
||||||
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
|
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
|
||||||
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
|
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
|
||||||
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), near(lat, lon, m) als Condition-Funktion
|
- **Trigger** — Timer + Watcher anlegen/anzeigen/loeschen, Live-Variablen-Anzeige (disk_free, current_lat, hour_of_day, …), GPS-Funktionen `near() / entered_near() / left_near()` für unterschiedliche Geofencing-Modi
|
||||||
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
|
||||||
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
||||||
|
|
||||||
@@ -319,7 +319,14 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
|
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
|
||||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
||||||
|
- `near(lat, lon, r)` — SOLANGE im Radius (mit Throttle gegen Spam). Use-Case: „bin ich noch in der Nähe von X?"
|
||||||
|
- `entered_near(lat, lon, r)` — EINMAL beim Eintritt (Übergang außen→innen). Use-Case: Blitzer-Warner mit r=2000 → 2 km Vorwarnung, oder Ankunfts-Erinnerung mit r=100
|
||||||
|
- `left_near(lat, lon, r)` — EINMAL beim Verlassen (Übergang innen→außen). Use-Case: „Hast du am Parkplatz X was vergessen?"
|
||||||
|
|
||||||
|
Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
|
||||||
|
|
||||||
|
**Auflösung**: Background-Loop tickt alle 8s (vorher 30s — bei 100 km/h durch einen 300m-Radius war eine Vorbeifahrt nur ~22s drin und konnte verpasst werden). Plus event-getrieben: Bridge ruft nach jedem `location_update` von der App sofort einen `/triggers/check-now` im Brain — Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten (verhindert Phantom-Fires bei abgeschaltetem Tracking).
|
||||||
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
|
||||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||||
|
|
||||||
@@ -357,6 +364,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
- **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 filtert Nachrichten live
|
||||||
- **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
|
- **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
|
||||||
|
- **App-Crash-Reporting**: ungefangene JS-Errors + React-Render-Fehler landen automatisch in `/shared/logs/app.log` via RVS — kein ADB noetig, Logs holen via `tools/fetch-app-logs.sh` oder Diagnostic GET `/api/app-log`. ErrorBoundary verhindert White-Screen, zeigt stattdessen Error-Box im Modal mit Stack-Trace + Schliessen-Button
|
||||||
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
|
||||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||||
@@ -867,10 +877,12 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
||||||
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
|
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
|
||||||
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
|
||||||
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, drei GPS-Funktionen `near()` / `entered_near()` / `left_near()` für unterschiedliche Geofencing-Modi, Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Tick-Frequenz 8s + event-getriebene Auswertung bei jedem `location_update` (statt 30s-Polling) damit auch Auto-Vorbeifahrten bei 100+ km/h durch kleine Radien zuverlässig erwischt werden. `near()`-Funktionen ignorieren GPS-Daten älter als 5 Minuten. Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
|
||||||
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
|
||||||
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
|
||||||
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
|
||||||
|
- [x] **Memory-Editor in der App** (5 Etappen): Notizen-Inbox-Button neben der Lupe oeffnet ein Modal mit allen Spezial-Bubbles aus dem aktuellen Chat plus dem vollen DB-Browser. Tap auf eine Memory → Detail-Modal mit Anhang-Vorschau, Stift-Icon wechselt in Edit-Mode (Felder editieren + Anhaenge hoch-/runterladen + loeschen). Identischer Editor unter Settings → 🧠 Gedaechtnis. Bubble-Header dynamic je nach Aktion (created/updated/deleted). RVS-Brain-Proxy als Fundament (`brain_request`/`brain_response`) damit die App beliebige Brain-HTTP-Endpoints adressieren kann. `memory_search` + `memory_update` als ARIA-Tools damit sie aktiv die DB pruefen und Eintraege patchen kann statt zu fragmentieren.
|
||||||
|
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary + global JS-Error-Handler + Promise-Rejection-Tracker schicken Crashes als `app_log`-Event durch RVS. Bridge sammelt in `/shared/logs/app.log`, Diagnostic GET `/api/app-log`. `tools/fetch-app-logs.sh` holt die Logs auf die Dev-Maschine (gitignored `.aria-debug/`). Damit kann Stefan unterwegs ohne ADB debuggen — der erste Bug (URLSearchParams in Hermes) wurde so in 5 Minuten gefunden.
|
||||||
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
|
||||||
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
|
||||||
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10306
|
versionCode 10403
|
||||||
versionName "0.1.3.6"
|
versionName "0.1.4.3"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.3.6",
|
"version": "0.1.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
|
|||||||
data={filtered}
|
data={filtered}
|
||||||
keyExtractor={m => m.id}
|
keyExtractor={m => m.id}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
|
// nestedScrollEnabled: notwendig damit die FlatList auf Android
|
||||||
|
// scrollt wenn sie in einer aeusseren ScrollView haengt (Settings-
|
||||||
|
// Screen ist ScrollView). Ohne das frisst der aeussere ScrollView
|
||||||
|
// alle Gesten und die innere Liste ist tot.
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
||||||
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ interface ChatMessage {
|
|||||||
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
||||||
* wenn das chat_backup-Event vom Bridge zurueck kommt. */
|
* wenn das chat_backup-Event vom Bridge zurueck kommt. */
|
||||||
backupTs?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Konstanten ---
|
// --- Konstanten ---
|
||||||
@@ -236,6 +246,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||||
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
||||||
const [inboxVisible, setInboxVisible] = useState(false);
|
const [inboxVisible, setInboxVisible] = useState(false);
|
||||||
|
const [showJumpDown, setShowJumpDown] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchVisible, setSearchVisible] = useState(false);
|
const [searchVisible, setSearchVisible] = useState(false);
|
||||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
@@ -259,6 +270,17 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const messageIdCounter = useRef(0);
|
const messageIdCounter = useRef(0);
|
||||||
|
// 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
|
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
|
||||||
// file_response wird die Datei nach dem Speichern direkt mit dem System-
|
// file_response wird die Datei nach dem Speichern direkt mit dem System-
|
||||||
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||||
@@ -270,6 +292,98 @@ const ChatScreen: React.FC = () => {
|
|||||||
return `msg_${Date.now()}_${messageIdCounter.current}`;
|
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.
|
||||||
|
const ACK_TIMEOUT_MS = 30_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'.
|
||||||
|
const dispatchWithAck = useCallback(
|
||||||
|
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
|
||||||
|
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);
|
||||||
|
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
|
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||||
// sofort greift, ohne Context- oder Event-System)
|
// sofort greift, ohne Context- oder Event-System)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -375,12 +489,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
const parsed: ChatMessage[] = JSON.parse(stored);
|
const parsed: ChatMessage[] = JSON.parse(stored);
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
console.log('[Chat] ${parsed.length} Nachrichten geladen');
|
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 maxId = parsed.reduce((max, msg) => {
|
||||||
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
||||||
return num > max ? num : max;
|
return num > max ? num : max;
|
||||||
}, 0);
|
}, 0);
|
||||||
messageIdCounter.current = maxId;
|
messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -418,6 +544,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
// RVS-Nachrichten abonnieren
|
// RVS-Nachrichten abonnieren
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
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
|
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
|
||||||
if (message.type === 'file_saved') {
|
if (message.type === 'file_saved') {
|
||||||
const serverPath = (message.payload.serverPath as string) || '';
|
const serverPath = (message.payload.serverPath as string) || '';
|
||||||
@@ -749,8 +891,17 @@ const ChatScreen: React.FC = () => {
|
|||||||
messageId: (message.payload.messageId as string) || undefined,
|
messageId: (message.payload.messageId as string) || undefined,
|
||||||
backupTs: (message.payload.backupTs as number) || 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
|
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
|
||||||
@@ -795,6 +946,21 @@ const ChatScreen: React.FC = () => {
|
|||||||
setAgentActivity({ activity, tool });
|
setAgentActivity({ activity, tool });
|
||||||
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
|
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
|
||||||
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
|
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
|
||||||
|
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
|
||||||
|
// activity-Event), Timer neu starten. 180s ohne Update → Hang.
|
||||||
|
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 3 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}]));
|
||||||
|
}, 180_000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
|
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
|
||||||
@@ -838,6 +1004,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const unsubState = rvs.onStateChange((state) => {
|
const unsubState = rvs.onStateChange((state) => {
|
||||||
setConnectionState(state);
|
setConnectionState(state);
|
||||||
|
connectionStateRef.current = state;
|
||||||
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
|
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
|
||||||
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
|
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
|
||||||
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
|
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
|
||||||
@@ -845,11 +1012,26 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Nachrichten vom Server, oder leeres Array wenn Server leer.
|
// Nachrichten vom Server, oder leeres Array wenn Server leer.
|
||||||
if (state === 'connected') {
|
if (state === 'connected') {
|
||||||
rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
|
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
|
// Initalen Status setzen
|
||||||
setConnectionState(rvs.getState());
|
const initialState = rvs.getState();
|
||||||
|
setConnectionState(initialState);
|
||||||
|
connectionStateRef.current = initialState;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubMessage();
|
unsubMessage();
|
||||||
@@ -1051,26 +1233,60 @@ const ChatScreen: React.FC = () => {
|
|||||||
setSearchIndex(0);
|
setSearchIndex(0);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
|
// Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
|
||||||
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
|
// neue Nachrichten kommen waehrend Suche aktiv ist → invertedMessages
|
||||||
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
|
// aendert sich, soll aber nicht den Scroll erneut triggern).
|
||||||
// damit Layout sicher fertig ist.
|
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.
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!searchMatchIds.length) return;
|
if (!searchMatchIds.length) {
|
||||||
|
lastSearchScrollKey.current = '';
|
||||||
|
clearPendingScrollRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = searchMatchIds[searchIndex];
|
const id = searchMatchIds[searchIndex];
|
||||||
if (!id) return;
|
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;
|
if (idx < 0 || !flatListRef.current) return;
|
||||||
const tryScroll = () => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
try {
|
||||||
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
||||||
} catch {
|
} catch {
|
||||||
// wird von onScrollToIndexFailed nochmal versucht
|
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
|
}, [searchIndex, searchMatchIds]);
|
||||||
requestAnimationFrame(tryScroll);
|
|
||||||
}, [searchIndex, searchMatchIds, invertedMessages]);
|
// Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
|
||||||
|
useEffect(() => () => {
|
||||||
|
clearPendingScrollRetry();
|
||||||
|
clearStuckWatchdog();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeSearchId = searchMatchIds[searchIndex] || '';
|
const activeSearchId = searchMatchIds[searchIndex] || '';
|
||||||
const gotoSearchPrev = () => {
|
const gotoSearchPrev = () => {
|
||||||
@@ -1150,29 +1366,33 @@ const ChatScreen: React.FC = () => {
|
|||||||
const wasInterrupted = interruptAriaIfBusy();
|
const wasInterrupted = interruptAriaIfBusy();
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
|
const cmid = nextClientMsgId();
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text,
|
text,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
clientMsgId: cmid,
|
||||||
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||||
|
sendAttempts: 1,
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
|
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
|
||||||
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
||||||
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
|
dispatchWithAck(cmid, 'chat', {
|
||||||
rvs.send('chat', {
|
|
||||||
text,
|
text,
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
interrupted: wasInterrupted,
|
interrupted: wasInterrupted,
|
||||||
...(location && { location }),
|
...(location && { location }),
|
||||||
});
|
});
|
||||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
|
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
||||||
|
|
||||||
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
|
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
|
||||||
const cancelRequest = useCallback(() => {
|
const cancelRequest = useCallback(() => {
|
||||||
setAgentActivity({ activity: 'idle', tool: '' });
|
setAgentActivity({ activity: 'idle', tool: '' });
|
||||||
|
clearStuckWatchdog();
|
||||||
rvs.send('cancel_request' as any, {});
|
rvs.send('cancel_request' as any, {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1189,6 +1409,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
|
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
|
||||||
if (thinking) {
|
if (thinking) {
|
||||||
setAgentActivity({ activity: 'idle', tool: '' });
|
setAgentActivity({ activity: 'idle', tool: '' });
|
||||||
|
clearStuckWatchdog();
|
||||||
rvs.send('cancel_request' as any, {});
|
rvs.send('cancel_request' as any, {});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1201,16 +1422,20 @@ const ChatScreen: React.FC = () => {
|
|||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
|
const cmid = nextClientMsgId();
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
audioRequestId,
|
audioRequestId,
|
||||||
|
clientMsgId: cmid,
|
||||||
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||||
|
sendAttempts: 1,
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
rvs.send('audio', {
|
dispatchWithAck(cmid, 'audio', {
|
||||||
base64: result.base64,
|
base64: result.base64,
|
||||||
durationMs: result.durationMs,
|
durationMs: result.durationMs,
|
||||||
mimeType: result.mimeType,
|
mimeType: result.mimeType,
|
||||||
@@ -1271,13 +1496,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 = {
|
const userMsg: ChatMessage = {
|
||||||
id: msgId,
|
id: msgId,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments,
|
attachments,
|
||||||
|
...(cmid && {
|
||||||
|
clientMsgId: cmid,
|
||||||
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||||
|
sendAttempts: 1,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
@@ -1311,9 +1543,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
|
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
||||||
if (messageText) {
|
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
||||||
rvs.send('chat', {
|
// adressieren.
|
||||||
|
if (messageText && cmid) {
|
||||||
|
dispatchWithAck(cmid, 'chat', {
|
||||||
text: messageText,
|
text: messageText,
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
speed: ttsSpeedRef.current,
|
speed: ttsSpeedRef.current,
|
||||||
@@ -1323,7 +1557,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
setInputText('');
|
setInputText('');
|
||||||
}, [pendingAttachments, getCurrentLocation]);
|
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||||
|
|
||||||
// --- Rendering ---
|
// --- Rendering ---
|
||||||
|
|
||||||
@@ -1375,17 +1609,30 @@ const ChatScreen: React.FC = () => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={`${item.id}-att-${idx}`}
|
key={`${item.id}-att-${idx}`}
|
||||||
style={styles.memoryAttachmentRow}
|
style={styles.memoryAttachmentRow}
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
if (!a.path) return;
|
if (!a.path) return;
|
||||||
if (a.localUri) {
|
if (a.localUri) {
|
||||||
if (isImage) setFullscreenImage(a.localUri);
|
const localPath = a.localUri.replace(/^file:\/\//, '');
|
||||||
else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || '');
|
const exists = await RNFS.exists(localPath).catch(() => false);
|
||||||
} else {
|
if (exists) {
|
||||||
// Datei via Bridge nachladen — file_response hat den
|
if (isImage) setFullscreenImage(a.localUri);
|
||||||
// memorySaved-Match-Path und cached + zeigt direkt
|
else openFileWithIntent(localPath, a.mime || '');
|
||||||
autoOpenPaths.current.add(a.path);
|
return;
|
||||||
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
|
}
|
||||||
|
// Cache weg → localUri leeren + neu laden
|
||||||
|
setMessages(prev => prev.map(mm => mm.id === item.id && mm.memorySaved
|
||||||
|
? { ...mm, memorySaved: { ...mm.memorySaved,
|
||||||
|
attachments: mm.memorySaved.attachments?.map(x =>
|
||||||
|
x.path === a.path ? { ...x, localUri: undefined } : x) } }
|
||||||
|
: mm));
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show('Cache leer — lade nach...', ToastAndroid.SHORT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Datei via Bridge nachladen — file_response hat den
|
||||||
|
// memorySaved-Match-Path und cached + zeigt direkt
|
||||||
|
autoOpenPaths.current.add(a.path);
|
||||||
|
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.memoryAttachmentIcon}>{icon}</Text>
|
<Text style={styles.memoryAttachmentIcon}>{icon}</Text>
|
||||||
@@ -1489,17 +1736,32 @@ const ChatScreen: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.attachmentFile}
|
style={styles.attachmentFile}
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
// Lokal vorhanden \u2192 direkt mit System-Intent oeffnen
|
// Lokal vorhanden? Cache koennte geleert worden sein \u2014
|
||||||
|
// Datei-Existenz pruefen bevor wir den Intent feuern.
|
||||||
if (att.uri) {
|
if (att.uri) {
|
||||||
openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || '');
|
const localPath = att.uri.replace(/^file:\/\//, '');
|
||||||
return;
|
const exists = await RNFS.exists(localPath).catch(() => false);
|
||||||
|
if (exists) {
|
||||||
|
openFileWithIntent(localPath, att.mimeType || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cache weg \u2192 uri im State leeren damit UI "tippen zum Laden" zeigt
|
||||||
|
setMessages(prev => prev.map(m => m.id === item.id
|
||||||
|
? { ...m, attachments: m.attachments?.map(a =>
|
||||||
|
a.serverPath === att.serverPath ? { ...a, uri: undefined } : a) }
|
||||||
|
: m));
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show('Cache leer \u2014 lade nach...', ToastAndroid.SHORT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Sonst: file_request \u2192 bei file_response wird die Datei
|
// Re-Download via file_request \u2192 bei file_response wird die
|
||||||
// gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||||
if (att.serverPath) {
|
if (att.serverPath) {
|
||||||
autoOpenPaths.current.add(att.serverPath);
|
autoOpenPaths.current.add(att.serverPath);
|
||||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||||
|
} else if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1562,7 +1824,31 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
|
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : 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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1698,15 +1984,26 @@ const ChatScreen: React.FC = () => {
|
|||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
inverted
|
inverted
|
||||||
data={invertedMessages}
|
data={invertedMessages}
|
||||||
|
onScroll={(e) => {
|
||||||
|
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
|
||||||
|
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
|
||||||
|
const y = e.nativeEvent.contentOffset.y;
|
||||||
|
setShowJumpDown(y > 250);
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={120}
|
||||||
onScrollToIndexFailed={(info) => {
|
onScrollToIndexFailed={(info) => {
|
||||||
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
|
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
||||||
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
|
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
|
||||||
// praezise nochmal versuchen.
|
// 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;
|
const offset = info.averageItemLength * info.index;
|
||||||
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
||||||
setTimeout(() => {
|
clearPendingScrollRetry();
|
||||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
|
pendingScrollRetry.current = setTimeout(() => {
|
||||||
}, 250);
|
pendingScrollRetry.current = null;
|
||||||
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
||||||
|
}, 300);
|
||||||
}}
|
}}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
@@ -1773,6 +2070,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Jump-to-Bottom-Button — erscheint wenn man weg von der neuesten
|
||||||
|
Nachricht gescrollt hat. Bei inverted FlatList ist scrollToOffset
|
||||||
|
0 == neueste Nachricht visuell unten. */}
|
||||||
|
{showJumpDown && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.jumpDownBtn}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={() => {
|
||||||
|
try {
|
||||||
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
} catch {}
|
||||||
|
setShowJumpDown(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'#fff', fontSize:18, fontWeight:'700'}}>{'↓'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Eingabebereich */}
|
{/* Eingabebereich */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
{/* Datei-Buttons */}
|
{/* Datei-Buttons */}
|
||||||
@@ -2111,6 +2426,35 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
alignSelf: 'flex-end',
|
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: {
|
emptyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -2313,6 +2657,23 @@ const styles = StyleSheet.create({
|
|||||||
color: '#555570',
|
color: '#555570',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
},
|
},
|
||||||
|
jumpDownBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
bottom: 80,
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: '#0096FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 5,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
bubbleTrash: {
|
bubbleTrash: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
|
|||||||
@@ -868,7 +868,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
||||||
|
|
||||||
{currentSection === null && (
|
{currentSection === null && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ function _newRequestId(): string {
|
|||||||
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mini-Query-String-Builder ohne URLSearchParams (Hermes-Polyfill kennt
|
||||||
|
* kein URLSearchParams.set, crasht). Akzeptiert object mit string/number/
|
||||||
|
* bool-Values; undefined/null/leere Strings werden ausgelassen. */
|
||||||
|
function _qs(params: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||||
|
}
|
||||||
|
return parts.length ? `?${parts.join('&')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
interface SendOpts {
|
interface SendOpts {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
body?: AnyJson;
|
body?: AnyJson;
|
||||||
@@ -119,29 +131,31 @@ export const brainApi = {
|
|||||||
|
|
||||||
/** Liste aller Memories, optional nach Type gefiltert. */
|
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||||
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||||
const qs = new URLSearchParams();
|
const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
|
||||||
if (opts.type) qs.set('type', opts.type);
|
return _send(`/memory/list${qs}`);
|
||||||
qs.set('limit', String(opts.limit || 500));
|
|
||||||
return _send(`/memory/list?${qs.toString()}`);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Volltext-Substring-Suche. */
|
/** Volltext-Substring-Suche. */
|
||||||
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
||||||
const qs = new URLSearchParams({ q });
|
const qs = _qs({
|
||||||
if (opts.type) qs.set('type', opts.type);
|
q,
|
||||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
type: opts.type,
|
||||||
qs.set('k', String(opts.k || 50));
|
include_pinned: opts.includePinned !== false,
|
||||||
return _send(`/memory/search-text?${qs.toString()}`);
|
k: opts.k || 50,
|
||||||
|
});
|
||||||
|
return _send(`/memory/search-text${qs}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Semantische Suche (Embedder). */
|
/** Semantische Suche (Embedder). */
|
||||||
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
||||||
const qs = new URLSearchParams({ q });
|
const qs = _qs({
|
||||||
if (opts.type) qs.set('type', opts.type);
|
q,
|
||||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
type: opts.type,
|
||||||
qs.set('k', String(opts.k || 10));
|
include_pinned: opts.includePinned !== false,
|
||||||
qs.set('score_threshold', String(opts.threshold ?? 0.30));
|
k: opts.k || 10,
|
||||||
return _send(`/memory/search?${qs.toString()}`);
|
score_threshold: opts.threshold ?? 0.30,
|
||||||
|
});
|
||||||
|
return _send(`/memory/search${qs}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Memory anlegen. */
|
/** Memory anlegen. */
|
||||||
|
|||||||
+11
-2
@@ -134,10 +134,19 @@ META_TOOLS = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "trigger_watcher",
|
"name": "trigger_watcher",
|
||||||
"description": (
|
"description": (
|
||||||
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
|
"Lege einen Watcher-Trigger an — pollt eine Condition, "
|
||||||
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
|
||||||
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
|
||||||
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
|
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
|
||||||
|
"Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
|
||||||
|
"- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
|
||||||
|
"Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
|
||||||
|
"- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
|
||||||
|
"Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
|
||||||
|
"wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
|
||||||
|
"nur gegen GPS-Jitter).\n"
|
||||||
|
"- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
|
||||||
|
"Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
+68
-19
@@ -27,7 +27,12 @@ import watcher as watcher_mod
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TICK_SEC = 30
|
# Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt
|
||||||
|
# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett
|
||||||
|
# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h
|
||||||
|
# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig
|
||||||
|
# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm.
|
||||||
|
TICK_SEC = 8
|
||||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _tick(agent_factory) -> None:
|
async def _tick(agent_factory) -> None:
|
||||||
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
|
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
|
||||||
|
|
||||||
|
near()-State-Tracking: entered_near/left_near brauchen die Information
|
||||||
|
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
|
||||||
|
Wir halten das pro Trigger als near_states-Dict im Manifest und
|
||||||
|
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
|
||||||
try:
|
try:
|
||||||
all_triggers = triggers_mod.list_triggers(active_only=True)
|
all_triggers = triggers_mod.list_triggers(active_only=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
|
|||||||
if not all_triggers:
|
if not all_triggers:
|
||||||
return
|
return
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
|
|
||||||
try:
|
|
||||||
vars_ = watcher_mod.collect_variables()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("collect_variables: %s", e)
|
|
||||||
vars_ = {}
|
|
||||||
|
|
||||||
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
|
|
||||||
# damit der Check-Interval respektiert wird)
|
|
||||||
for t in all_triggers:
|
|
||||||
if t.get("type") == "watcher":
|
|
||||||
try:
|
|
||||||
t["last_checked_at"] = _now_iso()
|
|
||||||
triggers_mod.write(t["name"], t)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for trigger in all_triggers:
|
for trigger in all_triggers:
|
||||||
|
if trigger.get("type") != "watcher":
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if _should_fire(trigger, vars_, now):
|
# Variablen pro Trigger sammeln — wegen prev_near_states-Closure
|
||||||
|
prev = trigger.get("near_states") or {}
|
||||||
|
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
|
||||||
|
|
||||||
|
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
|
||||||
|
fired = _should_fire(trigger, vars_, now)
|
||||||
|
|
||||||
|
# State immer updaten, egal ob gefeuert wurde — sonst greift
|
||||||
|
# entered_near/left_near nicht
|
||||||
|
new_states = vars_.get("_new_near_states") or {}
|
||||||
|
trigger["near_states"] = new_states
|
||||||
|
trigger["last_checked_at"] = _now_iso()
|
||||||
|
try:
|
||||||
|
triggers_mod.write(trigger["name"], trigger)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
if fired:
|
||||||
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
||||||
# darf der naechste Tick nicht blockieren
|
# darf der naechste Tick nicht blockieren
|
||||||
asyncio.create_task(_fire(trigger, agent_factory))
|
asyncio.create_task(_fire(trigger, agent_factory))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
# Timer (one-shot) — separat ohne near-State
|
||||||
|
timer_vars = None
|
||||||
|
for trigger in all_triggers:
|
||||||
|
if trigger.get("type") != "timer":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if timer_vars is None:
|
||||||
|
timer_vars = watcher_mod.collect_variables()
|
||||||
|
if _should_fire(trigger, timer_vars, now):
|
||||||
|
asyncio.create_task(_fire(trigger, agent_factory))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Timer-Check %s: %s", trigger.get("name"), e)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von
|
||||||
|
# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen
|
||||||
|
# Lifespan-Pfad geschleust zu werden.
|
||||||
|
_AGENT_FACTORY = None
|
||||||
|
|
||||||
|
|
||||||
|
async def tick_now() -> dict:
|
||||||
|
"""Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick.
|
||||||
|
Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach
|
||||||
|
_persist_location, damit Watcher mit near() den frischen Wert sofort
|
||||||
|
sehen statt bis zu TICK_SEC Sekunden zu warten."""
|
||||||
|
if _AGENT_FACTORY is None:
|
||||||
|
return {"ok": False, "error": "Background-Loop noch nicht gestartet"}
|
||||||
|
try:
|
||||||
|
await _tick(_AGENT_FACTORY)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("tick_now: %s", exc)
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
async def run_loop(agent_factory) -> None:
|
async def run_loop(agent_factory) -> None:
|
||||||
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
||||||
|
global _AGENT_FACTORY
|
||||||
|
_AGENT_FACTORY = agent_factory
|
||||||
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -657,6 +657,16 @@ def triggers_list(active_only: bool = False):
|
|||||||
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/triggers/check-now")
|
||||||
|
async def triggers_check_now():
|
||||||
|
"""Sofortiger Trigger-Check, statt auf den naechsten Background-Tick
|
||||||
|
zu warten. Wird von der Bridge nach jedem location_update gerufen
|
||||||
|
damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei
|
||||||
|
Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s
|
||||||
|
Drinnen-Zeit, was unter TICK_SEC fallen kann."""
|
||||||
|
return await background_mod.tick_now()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/triggers/conditions")
|
@app.get("/triggers/conditions")
|
||||||
def triggers_conditions():
|
def triggers_conditions():
|
||||||
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
||||||
|
|||||||
+81
-7
@@ -25,7 +25,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
|
|||||||
|
|
||||||
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
|
|
||||||
|
# Maximales GPS-Alter fuer near()-Auswertung. Wenn die App laenger nicht
|
||||||
|
# gepushed hat (z.B. Tracking aus, Mobilfunk weg, App geschlossen), gilt
|
||||||
|
# die Position als "unbekannt" und near() liefert False — verhindert
|
||||||
|
# Phantom-Fires basierend auf einer wochen-alten Position.
|
||||||
|
NEAR_MAX_AGE_SEC = 5 * 60
|
||||||
|
|
||||||
|
|
||||||
def _gps_state() -> dict[str, Any]:
|
def _gps_state() -> dict[str, Any]:
|
||||||
"""Letzte bekannte Position aus /shared/state/location.json.
|
"""Letzte bekannte Position aus /shared/state/location.json.
|
||||||
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
|
|||||||
return int(time.time() - ts)
|
return int(time.time() - ts)
|
||||||
|
|
||||||
|
|
||||||
def collect_variables() -> dict[str, Any]:
|
def _near_key(lat: float, lon: float, radius_m: float) -> str:
|
||||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
|
"""Stabiler Schluessel pro near()-Aufruf — fuer entered_near/left_near
|
||||||
|
State-Tracking pro Trigger pro Aufrufstelle."""
|
||||||
|
return f"{float(lat):.6f},{float(lon):.6f},{int(float(radius_m))}"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_variables(prev_near_states: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
|
||||||
|
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.
|
||||||
|
|
||||||
|
prev_near_states: pro Trigger gespeicherter Zustand vom letzten Eval
|
||||||
|
(für entered_near/left_near). Wird vom background-Loop reingegeben.
|
||||||
|
Nach dem Eval kann man `vars_['_new_near_states']` auslesen, um den
|
||||||
|
Update-Snapshot zurueck ins Trigger-Manifest zu schreiben."""
|
||||||
|
if prev_near_states is None:
|
||||||
|
prev_near_states = {}
|
||||||
|
new_near_states: Dict[str, bool] = {}
|
||||||
free_gb, free_pct = _disk_stats()
|
free_gb, free_pct = _disk_stats()
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
gps = _gps_state()
|
gps = _gps_state()
|
||||||
@@ -176,12 +196,17 @@ def collect_variables() -> dict[str, Any]:
|
|||||||
|
|
||||||
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
|
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
|
||||||
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
||||||
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt."""
|
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.
|
||||||
|
Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als
|
||||||
|
veraltet betrachtet → False."""
|
||||||
cur_lat = vars_.get("current_lat")
|
cur_lat = vars_.get("current_lat")
|
||||||
cur_lon = vars_.get("current_lon")
|
cur_lon = vars_.get("current_lon")
|
||||||
if cur_lat is None or cur_lon is None:
|
if cur_lat is None or cur_lon is None:
|
||||||
return False
|
return False
|
||||||
|
age = vars_.get("location_age_sec")
|
||||||
|
if isinstance(age, (int, float)) and age >= 0 and age > NEAR_MAX_AGE_SEC:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
R = 6371000.0
|
R = 6371000.0
|
||||||
phi1 = math.radians(float(cur_lat))
|
phi1 = math.radians(float(cur_lat))
|
||||||
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True solange im Radius drin. Plus State-Tracking fuer
|
||||||
|
entered_near/left_near — wir merken uns das letzte Ergebnis
|
||||||
|
damit Uebergaenge erkannt werden koennen."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
new_near_states[_near_key(lat, lon, radius_m)] = current
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _entered_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True NUR beim Uebergang draussen → innen. Use-Case: einmal
|
||||||
|
feuern wenn der User in den Radius reinfaehrt (Blitzer-Warner,
|
||||||
|
Ankunft-Erinnerung). Bei groesserem Radius = Vorwarnung."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
key = _near_key(lat, lon, radius_m)
|
||||||
|
new_near_states[key] = current
|
||||||
|
prev = bool(prev_near_states.get(key, False))
|
||||||
|
return current and not prev
|
||||||
|
|
||||||
|
def _left_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||||
|
"""True NUR beim Uebergang innen → draussen. Use-Case: 'Hast
|
||||||
|
du am Parkplatz X was vergessen?' beim Verlassen."""
|
||||||
|
current = _compute_near(lat, lon, radius_m)
|
||||||
|
key = _near_key(lat, lon, radius_m)
|
||||||
|
new_near_states[key] = current
|
||||||
|
prev = bool(prev_near_states.get(key, False))
|
||||||
|
return prev and not current
|
||||||
|
|
||||||
vars_["near"] = _near
|
vars_["near"] = _near
|
||||||
|
vars_["entered_near"] = _entered_near
|
||||||
|
vars_["left_near"] = _left_near
|
||||||
|
# Update-Snapshot fuer den Caller (background-Loop schreibt das pro
|
||||||
|
# Trigger zurueck damit beim naechsten Tick prev_near_states stimmt)
|
||||||
|
vars_["_new_near_states"] = new_near_states
|
||||||
return vars_
|
return vars_
|
||||||
|
|
||||||
|
|
||||||
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
|
|||||||
{
|
{
|
||||||
"name": "near",
|
"name": "near",
|
||||||
"signature": "near(lat, lon, radius_m)",
|
"signature": "near(lat, lon, radius_m)",
|
||||||
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern "
|
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
|
||||||
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.",
|
"Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
|
||||||
|
"Use-Case: 'bin noch in der Naehe von X?'. "
|
||||||
|
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "entered_near",
|
||||||
|
"signature": "entered_near(lat, lon, radius_m)",
|
||||||
|
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
|
||||||
|
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
|
||||||
|
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
|
||||||
|
"Vorwarnung bevor man am Punkt ist.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "left_near",
|
||||||
|
"signature": "left_near(lat, lon, radius_m)",
|
||||||
|
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
|
||||||
|
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
|
||||||
|
"vergessen?' beim Wegfahren.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+79
-1
@@ -25,6 +25,7 @@ import time
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -475,6 +476,13 @@ class ARIABridge:
|
|||||||
self.current_mode = self._load_persisted_mode()
|
self.current_mode = self._load_persisted_mode()
|
||||||
self.running = False
|
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)
|
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
|
||||||
self.tts_enabled = True
|
self.tts_enabled = True
|
||||||
self.xtts_voice = ""
|
self.xtts_voice = ""
|
||||||
@@ -938,7 +946,12 @@ class ARIABridge:
|
|||||||
def _persist_location(self, location: Optional[dict]) -> None:
|
def _persist_location(self, location: Optional[dict]) -> None:
|
||||||
"""Speichert die letzte bekannte GPS-Position fuer Watcher.
|
"""Speichert die letzte bekannte GPS-Position fuer Watcher.
|
||||||
Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende
|
Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende
|
||||||
Koordinaten werden ignoriert."""
|
Koordinaten werden ignoriert.
|
||||||
|
|
||||||
|
Plus: triggert sofort einen on-demand Trigger-Check im Brain
|
||||||
|
(POST /triggers/check-now). Ohne das wartet der Watcher-Loop
|
||||||
|
bis zu TICK_SEC Sekunden — bei Auto-Vorbeifahrt durch einen
|
||||||
|
300m-Radius (18-43s drin) kann das den Trigger verpassen."""
|
||||||
if not isinstance(location, dict):
|
if not isinstance(location, dict):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -950,9 +963,31 @@ class ARIABridge:
|
|||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
"lon": float(lon),
|
"lon": float(lon),
|
||||||
})
|
})
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
|
||||||
|
# oder langsam ist, blockt das nicht den GPS-Pfad.
|
||||||
|
try:
|
||||||
|
asyncio.create_task(self._trigger_brain_check_now())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _trigger_brain_check_now(self) -> None:
|
||||||
|
"""Brain-Endpoint POST /triggers/check-now anstossen."""
|
||||||
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
|
def _post():
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{brain_url}/triggers/check-now",
|
||||||
|
data=b"", method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
|
return r.status
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, _post)
|
||||||
|
|
||||||
def _persist_user_activity(self) -> None:
|
def _persist_user_activity(self) -> None:
|
||||||
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
|
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
|
||||||
Watcher: last_user_message_ago_sec basiert darauf."""
|
Watcher: last_user_message_ago_sec basiert darauf."""
|
||||||
@@ -1503,6 +1538,36 @@ class ARIABridge:
|
|||||||
except Exception:
|
except Exception:
|
||||||
break
|
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:
|
async def _handle_rvs_message(self, raw_message: str) -> None:
|
||||||
"""Verarbeitet Nachrichten von der App (via RVS).
|
"""Verarbeitet Nachrichten von der App (via RVS).
|
||||||
|
|
||||||
@@ -1527,6 +1592,13 @@ class ARIABridge:
|
|||||||
sender = payload.get("sender", "")
|
sender = payload.get("sender", "")
|
||||||
if sender in ("aria", "stt"):
|
if sender in ("aria", "stt"):
|
||||||
return
|
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", "")
|
text = payload.get("text", "")
|
||||||
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
|
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
|
||||||
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
|
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
|
||||||
@@ -2126,6 +2198,12 @@ class ARIABridge:
|
|||||||
|
|
||||||
elif msg_type == "audio":
|
elif msg_type == "audio":
|
||||||
# Audio von der App → decodieren → STT → an aria-core
|
# 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", "")
|
audio_b64 = payload.get("base64", "")
|
||||||
mime_type = payload.get("mimeType", "audio/mp4")
|
mime_type = payload.get("mimeType", "audio/mp4")
|
||||||
duration_ms = payload.get("durationMs", 0)
|
duration_ms = payload.get("durationMs", 0)
|
||||||
|
|||||||
@@ -297,6 +297,23 @@ Skills mit Tool-Use.
|
|||||||
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
||||||
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, ℹ-Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
|
||||||
|
|
||||||
|
### GPS-Trigger-Verbesserungen (entered_near + left_near + Timing-Fix)
|
||||||
|
|
||||||
|
- [x] **near() bei Auto-Vorbeifahrten verpasst — gefixt**: Background-Loop tickte alle 30s, Vorbeifahrt durch 300m-Radius bei 50-120 km/h dauert nur 18-43s → Tick konnte komplett dazwischen liegen. Fix: `TICK_SEC` 30 → 8 (Loop ist billig, Brain merkt das nicht). Plus event-getrieben: Bridge ruft nach jedem `location_update` ein POST `/triggers/check-now` im Brain → Watcher sehen die frische Position in Millisekunden statt im Polling-Takt. Polling läuft parallel als Fallback für Watcher ohne GPS-Bezug
|
||||||
|
- [x] **near() Age-Schutz**: GPS-Daten älter als 5 Minuten (`NEAR_MAX_AGE_SEC=300`) gelten als veraltet → `near()` liefert False. Vorher hätte ein wochen-alter Wert die Funktion weiter als „in der Nähe" eingeordnet → Phantom-Fires wenn Tracking aus war
|
||||||
|
- [x] **Drei GPS-Modi statt einem**: `near()` bleibt = „solange drin". Neu: **`entered_near(lat, lon, r)`** feuert NUR beim Übergang außen→innen (Blitzer-Warner mit r=2000 = 2 km Vorwarnung, Ankunft mit r=100), **`left_near(lat, lon, r)`** feuert NUR beim Übergang innen→außen („Hast du am Parkplatz was vergessen?"). State-Tracking pro Trigger pro near-Aufruf (`near_states`-Dict im Manifest) — Background-Loop schreibt den letzten Auswertungswert immer zurück, damit beim nächsten Tick die Übergangs-Erkennung greift. ARIA's `trigger_watcher`-Tool-Description erklärt die drei Modi inkl. empfohlener Throttle-Werte (kurz für entered/left, lang für near)
|
||||||
|
|
||||||
|
### App-Memory-Editor + Crash-Reporting
|
||||||
|
|
||||||
|
- [x] **Bubble-Header dynamic** (created/updated/deleted): Die `🧠`-Bubble zeigt jetzt was passiert ist — "ARIA hat etwas gemerkt" / "Notiz geändert" / "Notiz gelöscht" (rot bei delete). Brain-Tools schicken `action`-Feld im memory_saved-Event mit
|
||||||
|
- [x] **Tap auf Memory-Bubble → Detail-Modal**: Komponente `MemoryDetailModal` zeigt alle Felder (Titel, Type, Category, Tags, voller Content, Anhang-Vorschau mit Thumbnails). Stift-Icon wechselt in Edit-Mode mit Form-Feldern + 📌 Pinned-Toggle. **Anhänge hoch-/runterladen + löschen** im Modal (DocumentPicker, multipart-Upload via RVS-Brain-Proxy). Memory komplett löschen mit Confirm
|
||||||
|
- [x] **Notizen-Inbox-Button (`🗂️`)** neben der Lupe in der Status-Leiste: Vollbild-Modal mit zwei Sections — „Aus diesem Chat" (kompakte Liste der Spezial-Bubbles aus dem aktuellen Verlauf, klickbar) + „Alle Memories aus der DB" mit dem `MemoryBrowser`. Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) werden im Chat-Stream gefiltert (statt unten zu kleben)
|
||||||
|
- [x] **Memory-Editor in App-Settings**: neue Sektion 🧠 „Gedächtnis" in den App-Einstellungen. Komplette CRUD-UI mit Wortlich-Suche, Type-Dropdown, Pinned/Cold-Filter, „+ Neu" anlegen. Selbe `MemoryBrowser`-Komponente wie in der Inbox
|
||||||
|
- [x] **RVS-Brain-Proxy als Fundament**: Bridge implementiert generischen `brain_request` / `brain_response`-Channel — die App kann beliebige Brain-HTTP-Endpoints via RVS adressieren (GET/POST/PATCH/DELETE, JSON+Base64-Body, base64-encoded Binär-Antworten). `services/brainApi.ts` als Promise-basierter Client mit Request-ID-Routing, Timeout, automatischem Listener-Setup
|
||||||
|
- [x] **App-Crash-Reporting via RVS**: ErrorBoundary-Komponente fängt React-Render-Fehler, `installGlobalCrashReporter` haengt sich an `ErrorUtils.setGlobalHandler` + `HermesInternal.enablePromiseRejectionTracker`. Crashes wandern als `app_log`-Event durch RVS, Bridge schreibt JSONL in `/shared/logs/app.log`. Diagnostic-Server liefert GET `/api/app-log[?limit=N]` + POST `/api/app-log/clear`. **`tools/fetch-app-logs.sh`** holt die Logs auf die Dev-Maschine (über `ARIA_DIAG_URL` aus `.claude/aria-vm.env`), speichert in `.aria-debug/` (gitignored), zeigt Stack-Trace kompakt auf stdout
|
||||||
|
- [x] **`memory_search` + `memory_update` Tools**: ARIA kann die DB jetzt aktiv durchsuchen (Volltext/Semantic) und existierende Einträge per ID patchen statt fragmentierende neue anzulegen. Tool-Description sagt explizit „Memory ist Truth über Conversation-Window" — wenn der User korrigiert hat, gilt das was im Memory steht. Wichtig nach Diagnostic-Edits damit ARIA die neue Wahrheit sieht statt aus dem Window zu raten
|
||||||
|
- [x] **App-Bugfixes**: (a) URLSearchParams crasht in Hermes — durch Mini-Query-Builder ersetzt (`brainApi._qs()`). (b) Cache leer + Datei-Tap → Auto-Re-Download via file_request statt Toast-Sackgasse, plus State-Cleanup (uri/localUri auf undefined). (c) Memory-Liste in Settings scrollt jetzt (nestedScrollEnabled auf FlatList + äußere ScrollView). (d) Modal-im-Modal auf Android gefixt — MemoryBrowser nimmt optionalen `onOpenMemory`-Callback, kein verschachteltes DetailModal mehr. (e) Alert.prompt (iOS-only) durch eigenes Text-Input-Modal ersetzt fuer „Neue Memory anlegen"
|
||||||
|
|
||||||
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
|
||||||
|
|
||||||
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
|
||||||
@@ -331,7 +348,6 @@ Skills mit Tool-Use.
|
|||||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||||
|
|
||||||
### Architektur
|
### Architektur
|
||||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
|
||||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||||
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||||
|
|||||||
Reference in New Issue
Block a user