Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd | |||
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed | |||
| 3cf6308b79 | |||
| 7e5a4da659 | |||
| d27fcaf342 | |||
| 5b28a065c0 | |||
| e74e1eaf70 | |||
| ff7c6333bb | |||
| 2c85df3499 | |||
| 6f11f28448 | |||
| 21a315ca71 | |||
| d8b05082d6 | |||
| de91073b2e | |||
| e88b5f57bf |
@@ -25,6 +25,10 @@ aria-data/brain-import/*
|
|||||||
!aria-data/brain-import/.gitkeep
|
!aria-data/brain-import/.gitkeep
|
||||||
!aria-data/brain-import/README.md
|
!aria-data/brain-import/README.md
|
||||||
|
|
||||||
|
# .aria-debug/ — App-Crash-Logs die tools/fetch-app-logs.sh hier ablegt.
|
||||||
|
# Komplett lokal, enthaelt potentiell private Stacktraces / Daten.
|
||||||
|
.aria-debug/
|
||||||
|
|
||||||
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
|
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
|
||||||
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
|
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
|
||||||
aria-data/brain/data/
|
aria-data/brain/data/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-1
@@ -13,7 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|||||||
import ChatScreen from './src/screens/ChatScreen';
|
import ChatScreen from './src/screens/ChatScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -49,6 +49,9 @@ const App: React.FC = () => {
|
|||||||
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
||||||
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
||||||
initLogger().catch(() => {});
|
initLogger().catch(() => {});
|
||||||
|
// Crash-Reporter installieren — ungefangene JS-Errors landen via RVS
|
||||||
|
// bei der Bridge (sichtbar in /shared/logs/app.log + Diagnostic-API)
|
||||||
|
installGlobalCrashReporter();
|
||||||
const initConnection = async () => {
|
const initConnection = async () => {
|
||||||
const config = await rvs.loadConfig();
|
const config = await rvs.loadConfig();
|
||||||
if (config) {
|
if (config) {
|
||||||
|
|||||||
@@ -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 10302
|
versionCode 10402
|
||||||
versionName "0.1.3.2"
|
versionName "0.1.4.2"
|
||||||
// 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.2",
|
"version": "0.1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* ErrorBoundary — fängt React-Render-Fehler und zeigt eine Error-Box
|
||||||
|
* statt White-Screen-of-Death. Plus: Crash wird zum logger geschickt,
|
||||||
|
* der das ueber RVS an die Bridge weiterleitet.
|
||||||
|
*
|
||||||
|
* Einsatz: kritische Komponenten/Modals damit ein Bug nicht die ganze
|
||||||
|
* App killt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
import { reportAppError } from '../services/logger';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional: Bezeichnung der eingegrenzten Section fuer's Log. */
|
||||||
|
scope?: string;
|
||||||
|
/** Optional: Reset-Callback (z.B. Modal schliessen) — Button ist dann sichtbar. */
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
err: Error | null;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { err: null, info: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(err: Error): Partial<State> {
|
||||||
|
return { err };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(err: Error, info: any) {
|
||||||
|
const stack = info?.componentStack || '';
|
||||||
|
this.setState({ info: stack });
|
||||||
|
reportAppError({
|
||||||
|
scope: this.props.scope || 'ErrorBoundary',
|
||||||
|
message: err?.message || String(err),
|
||||||
|
stack: (err?.stack || '') + '\n--- componentStack ---\n' + stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.err) {
|
||||||
|
return (
|
||||||
|
<View style={s.box}>
|
||||||
|
<Text style={s.title}>⚠️ Etwas ist schiefgegangen</Text>
|
||||||
|
<Text style={s.scope}>{this.props.scope || 'unbekannte Komponente'}</Text>
|
||||||
|
<ScrollView style={s.scroll}>
|
||||||
|
<Text style={s.msg}>{this.state.err.message || String(this.state.err)}</Text>
|
||||||
|
{this.state.info ? <Text style={s.stack}>{this.state.info}</Text> : null}
|
||||||
|
</ScrollView>
|
||||||
|
{this.props.onReset ? (
|
||||||
|
<TouchableOpacity style={s.btn} onPress={() => { this.setState({err:null,info:''}); this.props.onReset?.(); }}>
|
||||||
|
<Text style={s.btnText}>Schliessen + zurueck</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={s.btn} onPress={() => this.setState({err:null,info:''})}>
|
||||||
|
<Text style={s.btnText}>Erneut versuchen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<Text style={s.hint}>
|
||||||
|
Crash wurde an die Bridge gemeldet — sichtbar in der Diagnostic-Web-UI unter /api/app-log
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
box: { flex:1, padding:16, backgroundColor:'#1A0A0A' },
|
||||||
|
title: { color:'#FF6B6B', fontWeight:'bold', fontSize:16, marginBottom:6 },
|
||||||
|
scope: { color:'#FF9500', fontSize:12, marginBottom:10 },
|
||||||
|
scroll: { flex:1, backgroundColor:'#0D0D1A', borderRadius:6, padding:10, marginBottom:10 },
|
||||||
|
msg: { color:'#FF6B6B', fontSize:13, marginBottom:8 },
|
||||||
|
stack: { color:'#8888AA', fontSize:11, fontFamily:'monospace' },
|
||||||
|
btn: { backgroundColor:'#0096FF', paddingVertical:10, borderRadius:6, alignItems:'center' },
|
||||||
|
btnText: { color:'#fff', fontWeight:'600' },
|
||||||
|
hint: { color:'#555570', fontSize:10, marginTop:8, textAlign:'center' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -37,9 +37,11 @@ interface Props {
|
|||||||
title?: string;
|
title?: string;
|
||||||
/** Style-Erweiterung fuer den Container. */
|
/** Style-Erweiterung fuer den Container. */
|
||||||
flatStyle?: boolean;
|
flatStyle?: boolean;
|
||||||
|
/** Wenn gesetzt: kein eigenes DetailModal mounten — Parent kuemmert sich. */
|
||||||
|
onOpenMemory?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle }) => {
|
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle, onOpenMemory }) => {
|
||||||
const [items, setItems] = useState<Memory[]>([]);
|
const [items, setItems] = useState<Memory[]>([]);
|
||||||
const [filtered, setFiltered] = useState<Memory[]>([]);
|
const [filtered, setFiltered] = useState<Memory[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -82,38 +84,35 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
|
|||||||
setFiltered(out);
|
setFiltered(out);
|
||||||
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
|
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
|
||||||
|
|
||||||
|
const [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false);
|
||||||
|
const [newMemoryTitle, setNewMemoryTitle] = useState('');
|
||||||
|
|
||||||
const onAddNew = () => {
|
const onAddNew = () => {
|
||||||
Alert.prompt(
|
setNewMemoryTitle('');
|
||||||
'Neue Memory',
|
setShowNewMemoryDialog(true);
|
||||||
'Titel:',
|
};
|
||||||
[
|
|
||||||
{ text: 'Abbrechen', style: 'cancel' },
|
const confirmAddNew = async () => {
|
||||||
{
|
const t = newMemoryTitle.trim();
|
||||||
text: 'Anlegen',
|
if (!t) { setShowNewMemoryDialog(false); return; }
|
||||||
onPress: async (title?: string) => {
|
setShowNewMemoryDialog(false);
|
||||||
const t = (title || '').trim();
|
|
||||||
if (!t) return;
|
|
||||||
try {
|
try {
|
||||||
const m = await brainApi.saveMemory({
|
const m = await brainApi.saveMemory({
|
||||||
type: 'fact', title: t,
|
type: 'fact', title: t,
|
||||||
content: '(noch leer — bitte editieren)',
|
content: '(noch leer — bitte editieren)',
|
||||||
});
|
});
|
||||||
load();
|
load();
|
||||||
setOpenId(m.id);
|
if (onOpenMemory) onOpenMemory(m.id);
|
||||||
|
else setOpenId(m.id);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('Fehler', String(e?.message || e));
|
Alert.alert('Fehler', String(e?.message || e));
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'plain-text',
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: Memory }) => {
|
const renderItem = ({ item }: { item: Memory }) => {
|
||||||
const attCount = (item.attachments || []).length;
|
const attCount = (item.attachments || []).length;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={s.row} onPress={() => setOpenId(item.id)}>
|
<TouchableOpacity style={s.row} onPress={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
|
||||||
<View style={{flex:1}}>
|
<View style={{flex:1}}>
|
||||||
<Text style={s.rowTitle} numberOfLines={1}>
|
<Text style={s.rowTitle} numberOfLines={1}>
|
||||||
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
|
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
|
||||||
@@ -170,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)'}
|
||||||
@@ -202,12 +207,42 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt
|
||||||
|
(vermeidet Modal-in-Modal-Stacking auf Android). */}
|
||||||
|
{!onOpenMemory && (
|
||||||
<MemoryDetailModal
|
<MemoryDetailModal
|
||||||
memoryId={openId}
|
memoryId={openId}
|
||||||
visible={!!openId}
|
visible={!!openId}
|
||||||
onClose={() => { setOpenId(null); load(); }}
|
onClose={() => { setOpenId(null); load(); }}
|
||||||
onDeleted={() => { setOpenId(null); load(); }}
|
onDeleted={() => { setOpenId(null); load(); }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* "Neue Memory"-Dialog (Alert.prompt ist iOS-only, daher eigenes Modal) */}
|
||||||
|
<Modal visible={showNewMemoryDialog} transparent animationType="fade" onRequestClose={() => setShowNewMemoryDialog(false)}>
|
||||||
|
<View style={s.menuBack}>
|
||||||
|
<View style={[s.menuBox, {padding:16, minWidth:280}]}>
|
||||||
|
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:14, marginBottom:10}}>Neue Memory anlegen</Text>
|
||||||
|
<Text style={{color:'#8888AA', fontSize:11, marginBottom:6}}>Titel:</Text>
|
||||||
|
<TextInput
|
||||||
|
value={newMemoryTitle}
|
||||||
|
onChangeText={setNewMemoryTitle}
|
||||||
|
autoFocus
|
||||||
|
placeholder="z.B. Stefans Auto"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
style={{backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:4, fontSize:13, marginBottom:12}}
|
||||||
|
/>
|
||||||
|
<View style={{flexDirection:'row', gap:8, justifyContent:'flex-end'}}>
|
||||||
|
<TouchableOpacity onPress={() => setShowNewMemoryDialog(false)} style={{padding:8}}>
|
||||||
|
<Text style={{color:'#8888AA'}}>Abbrechen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={confirmAddNew} style={{backgroundColor:'#0096FF', paddingHorizontal:14, paddingVertical:8, borderRadius:4}}>
|
||||||
|
<Text style={{color:'#fff', fontWeight:'600'}}>Anlegen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Dimensions } from 'react-native';
|
|||||||
import ZoomableImage from '../components/ZoomableImage';
|
import ZoomableImage from '../components/ZoomableImage';
|
||||||
import MemoryDetailModal from '../components/MemoryDetailModal';
|
import MemoryDetailModal from '../components/MemoryDetailModal';
|
||||||
import MemoryBrowser from '../components/MemoryBrowser';
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import wakeWordService from '../services/wakeword';
|
import wakeWordService from '../services/wakeword';
|
||||||
@@ -235,6 +236,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
|
||||||
@@ -1051,9 +1053,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
|
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
|
||||||
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
|
// FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport →
|
||||||
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
|
// Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar, kein
|
||||||
// damit Layout sicher fertig ist.
|
// weiteres Hochscrollen noetig. Plus mehrere Retries da Layout bei
|
||||||
|
// langen Listen zeitversetzt fertig wird.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchMatchIds.length) return;
|
if (!searchMatchIds.length) return;
|
||||||
const id = searchMatchIds[searchIndex];
|
const id = searchMatchIds[searchIndex];
|
||||||
@@ -1062,13 +1065,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (idx < 0 || !flatListRef.current) return;
|
if (idx < 0 || !flatListRef.current) return;
|
||||||
const tryScroll = () => {
|
const tryScroll = () => {
|
||||||
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
|
// wird von onScrollToIndexFailed nochmal versucht
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
|
// requestAnimationFrame fuer den ersten Versuch, dann setTimeout-Folge
|
||||||
|
// damit auch bei tiefen Indizes (viel ungelayoutete Items dazwischen)
|
||||||
|
// der Sprung am Ende sitzt.
|
||||||
requestAnimationFrame(tryScroll);
|
requestAnimationFrame(tryScroll);
|
||||||
|
[180, 420, 800].forEach(d => setTimeout(tryScroll, d));
|
||||||
}, [searchIndex, searchMatchIds, invertedMessages]);
|
}, [searchIndex, searchMatchIds, invertedMessages]);
|
||||||
|
|
||||||
const activeSearchId = searchMatchIds[searchIndex] || '';
|
const activeSearchId = searchMatchIds[searchIndex] || '';
|
||||||
@@ -1374,17 +1380,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) {
|
||||||
|
const localPath = a.localUri.replace(/^file:\/\//, '');
|
||||||
|
const exists = await RNFS.exists(localPath).catch(() => false);
|
||||||
|
if (exists) {
|
||||||
if (isImage) setFullscreenImage(a.localUri);
|
if (isImage) setFullscreenImage(a.localUri);
|
||||||
else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || '');
|
else openFileWithIntent(localPath, a.mime || '');
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
// 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
|
// Datei via Bridge nachladen — file_response hat den
|
||||||
// memorySaved-Match-Path und cached + zeigt direkt
|
// memorySaved-Match-Path und cached + zeigt direkt
|
||||||
autoOpenPaths.current.add(a.path);
|
autoOpenPaths.current.add(a.path);
|
||||||
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
|
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>
|
||||||
@@ -1488,17 +1507,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:\/\//, '');
|
||||||
|
const exists = await RNFS.exists(localPath).catch(() => false);
|
||||||
|
if (exists) {
|
||||||
|
openFileWithIntent(localPath, att.mimeType || '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sonst: file_request \u2192 bei file_response wird die Datei
|
// Cache weg \u2192 uri im State leeren damit UI "tippen zum Laden" zeigt
|
||||||
// gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-Download via file_request \u2192 bei file_response wird die
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1697,15 +1731,27 @@ 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. Zuerst grob in die
|
||||||
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
|
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann mehrfach
|
||||||
// praezise nochmal versuchen.
|
// praezise nachsetzen — bei langem Chat braucht's manchmal mehrere
|
||||||
|
// Runden bis die Layouts gemessen sind.
|
||||||
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 {}
|
||||||
|
// viewPosition 0 = Item-Top oben am Viewport → Stefan landet am
|
||||||
|
// Text-Anfang der Bubble, nicht in der Mitte oder am Ende.
|
||||||
|
[120, 320, 600].forEach(delay => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
||||||
}, 250);
|
}, delay);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
@@ -1772,6 +1818,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 */}
|
||||||
@@ -1839,17 +1903,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
|
{/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
|
||||||
|
{memoryDetailId ? (
|
||||||
|
<ErrorBoundary scope="ChatScreen.MemoryDetailModal" onReset={() => setMemoryDetailId(null)}>
|
||||||
<MemoryDetailModal
|
<MemoryDetailModal
|
||||||
memoryId={memoryDetailId}
|
memoryId={memoryDetailId}
|
||||||
visible={!!memoryDetailId}
|
visible={!!memoryDetailId}
|
||||||
onClose={() => setMemoryDetailId(null)}
|
onClose={() => setMemoryDetailId(null)}
|
||||||
onDeleted={() => setMemoryDetailId(null)}
|
onDeleted={() => setMemoryDetailId(null)}
|
||||||
/>
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
||||||
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
|
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
|
||||||
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
|
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
|
||||||
<Modal visible={inboxVisible} animationType="slide" onRequestClose={() => setInboxVisible(false)}>
|
<Modal visible={inboxVisible} animationType="slide" onRequestClose={() => setInboxVisible(false)}>
|
||||||
|
<ErrorBoundary scope="ChatScreen.InboxModal" onReset={() => setInboxVisible(false)}>
|
||||||
<View style={{flex:1, backgroundColor:'#0D0D1A'}}>
|
<View style={{flex:1, backgroundColor:'#0D0D1A'}}>
|
||||||
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
|
<View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
|
||||||
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>{'🗂️'} Notizen-Inbox</Text>
|
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>{'🗂️'} Notizen-Inbox</Text>
|
||||||
@@ -1933,8 +2002,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
Alle Memories aus der DB
|
Alle Memories aus der DB
|
||||||
</Text>
|
</Text>
|
||||||
<MemoryBrowser />
|
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
||||||
</View>
|
</View>
|
||||||
|
</ErrorBoundary>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Bild-Vollbild Modal */}
|
{/* Bild-Vollbild Modal */}
|
||||||
@@ -2306,6 +2376,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. */
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import rvs from './rvs';
|
||||||
|
|
||||||
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
||||||
|
|
||||||
@@ -39,3 +41,77 @@ export function setVerboseLogging(verbose: boolean): void {
|
|||||||
applyState();
|
applyState();
|
||||||
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
|
||||||
|
// ungefangener JS-Error (ErrorUtils-Handler) — schicken wir den Crash
|
||||||
|
// als RVS-Message vom Typ "app_log" an die Bridge. Die schreibt in
|
||||||
|
// /shared/logs/app.log, sodass wir/Diagnostic die Crashes mitlesen
|
||||||
|
// koennen ohne ADB.
|
||||||
|
|
||||||
|
interface AppErrorEvent {
|
||||||
|
scope: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
level?: 'error' | 'warn' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let _reportingInstalled = false;
|
||||||
|
|
||||||
|
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||||
|
export function reportAppError(ev: AppErrorEvent): void {
|
||||||
|
try {
|
||||||
|
rvs.send('app_log' as any, {
|
||||||
|
ts: Date.now(),
|
||||||
|
platform: Platform.OS,
|
||||||
|
level: ev.level || 'error',
|
||||||
|
scope: ev.scope,
|
||||||
|
message: ev.message,
|
||||||
|
stack: (ev.stack || '').slice(0, 8000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// RVS noch nicht connected — Fehler geht im console weiter.
|
||||||
|
}
|
||||||
|
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
|
||||||
|
// den Crash sieht.
|
||||||
|
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
|
||||||
|
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
|
||||||
|
export function installGlobalCrashReporter(): void {
|
||||||
|
if (_reportingInstalled) return;
|
||||||
|
_reportingInstalled = true;
|
||||||
|
try {
|
||||||
|
const g: any = global as any;
|
||||||
|
const prev = g.ErrorUtils?.getGlobalHandler?.();
|
||||||
|
g.ErrorUtils?.setGlobalHandler?.((err: any, isFatal: boolean) => {
|
||||||
|
reportAppError({
|
||||||
|
scope: isFatal ? 'global-fatal' : 'global-nonfatal',
|
||||||
|
message: (err && err.message) || String(err),
|
||||||
|
stack: err && err.stack,
|
||||||
|
});
|
||||||
|
// Original-Handler weiterhin aufrufen damit React-Native das System-
|
||||||
|
// Crash-Overlay zeigt (im Dev-Build) bzw. in Production sauber stirbt.
|
||||||
|
if (typeof prev === 'function') {
|
||||||
|
try { prev(err, isFatal); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// unhandled Promise-Rejections — manche RN-Versionen haben das nicht
|
||||||
|
// automatisch im ErrorUtils.
|
||||||
|
g.HermesInternal?.enablePromiseRejectionTracker?.({
|
||||||
|
allRejections: true,
|
||||||
|
onUnhandled: (id: number, err: any) => {
|
||||||
|
reportAppError({
|
||||||
|
scope: 'promise-unhandled',
|
||||||
|
level: 'warn',
|
||||||
|
message: (err && err.message) || String(err),
|
||||||
|
stack: err && err.stack,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ErrorUtils nicht da → nix machen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+51
-1
@@ -938,7 +938,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 +955,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."""
|
||||||
@@ -1824,6 +1851,29 @@ class ARIABridge:
|
|||||||
logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error"))
|
logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif msg_type == "app_log":
|
||||||
|
# App schickt Crash/Error/Info-Log via RVS — wir schreiben das
|
||||||
|
# in /shared/logs/app.log (JSONL) damit Diagnostic + Claude
|
||||||
|
# mitlesen koennen, auch ohne ADB-Zugriff aufs Handy.
|
||||||
|
try:
|
||||||
|
log_dir = Path("/shared/logs")
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
line = {
|
||||||
|
"ts": payload.get("ts") or int(time.time() * 1000),
|
||||||
|
"platform": payload.get("platform", "?"),
|
||||||
|
"level": payload.get("level", "info"),
|
||||||
|
"scope": payload.get("scope", ""),
|
||||||
|
"message": payload.get("message", ""),
|
||||||
|
"stack": payload.get("stack", ""),
|
||||||
|
}
|
||||||
|
with (log_dir / "app.log").open("a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(line, ensure_ascii=False) + "\n")
|
||||||
|
logger.info("[app-log] %s %s: %s",
|
||||||
|
line["level"], line["scope"], line["message"][:120])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[app-log] schreiben fehlgeschlagen: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "brain_request":
|
elif msg_type == "brain_request":
|
||||||
# Generischer RVS-Proxy fuer die Brain-HTTP-API.
|
# Generischer RVS-Proxy fuer die Brain-HTTP-API.
|
||||||
# payload: {requestId, method, path, body?, bodyBase64?, contentType?}
|
# payload: {requestId, method, path, body?, bodyBase64?, contentType?}
|
||||||
|
|||||||
@@ -1338,6 +1338,42 @@ const server = http.createServer((req, res) => {
|
|||||||
else broadcast({ type: "agent_activity", activity: "idle" });
|
else broadcast({ type: "agent_activity", activity: "idle" });
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ ok: true }));
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} else if (req.url.startsWith("/api/app-log") && req.method === "GET") {
|
||||||
|
// App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS,
|
||||||
|
// Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die
|
||||||
|
// letzten 200 Eintraege (oder ?limit=N).
|
||||||
|
const url = new URL(req.url, "http://x");
|
||||||
|
const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200));
|
||||||
|
try {
|
||||||
|
const file = "/shared/logs/app.log";
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ count: 0, entries: [] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(file, "utf-8");
|
||||||
|
const lines = raw.split("\n").filter(l => l.trim());
|
||||||
|
const tail = lines.slice(-limit);
|
||||||
|
const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ count: entries.length, entries }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (req.url === "/api/app-log/clear" && req.method === "POST") {
|
||||||
|
// App-Log leeren — nach erfolgreichem Debug.
|
||||||
|
try {
|
||||||
|
const file = "/shared/logs/app.log";
|
||||||
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: err.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else if (req.url === "/api/files-list" && req.method === "GET") {
|
} else if (req.url === "/api/files-list" && req.method === "GET") {
|
||||||
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
|
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
|
||||||
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
|
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"chat_history_request", "chat_history_response", "chat_cleared",
|
"chat_history_request", "chat_history_response", "chat_cleared",
|
||||||
"delete_message_request", "chat_message_deleted",
|
"delete_message_request", "chat_message_deleted",
|
||||||
"brain_request", "brain_response",
|
"brain_request", "brain_response",
|
||||||
|
"app_log",
|
||||||
"file_delete_batch_request", "file_delete_batch_response",
|
"file_delete_batch_request", "file_delete_batch_response",
|
||||||
"file_zip_request", "file_zip_response",
|
"file_zip_request", "file_zip_response",
|
||||||
"xtts_delete_voice",
|
"xtts_delete_voice",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# tools/
|
||||||
|
|
||||||
|
Hilfsskripte für die Dev-Maschine. Brauchen `.claude/aria-vm.env` (aus
|
||||||
|
`.example` kopieren + lokale VM-IP eintragen).
|
||||||
|
|
||||||
|
## fetch-app-logs.sh
|
||||||
|
|
||||||
|
Holt App-Crash-Logs von der VM und speichert sie unter `.aria-debug/`
|
||||||
|
(gitignored). Die App schickt JS-Errors und ungefangene Promise-
|
||||||
|
Rejections via RVS an die Bridge — Bridge sammelt in
|
||||||
|
`/shared/logs/app.log`, Diagnostic-Server gibt sie via
|
||||||
|
`GET /api/app-log` raus.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/fetch-app-logs.sh # 200 neueste Eintraege
|
||||||
|
tools/fetch-app-logs.sh --limit 50 # weniger
|
||||||
|
tools/fetch-app-logs.sh --watch # alle 5s pollen, neue rausgeben
|
||||||
|
tools/fetch-app-logs.sh --clear # nach Abholen Log auf VM leeren
|
||||||
|
```
|
||||||
|
|
||||||
|
Ausgabe enthaelt pro Eintrag: Uhrzeit, Level (error/warn/info), Scope
|
||||||
|
(z.B. `ChatScreen.InboxModal` oder `global-fatal`), Message, und die
|
||||||
|
ersten ~8 Stack-Frames. Die kompletten Daten liegen als JSON in
|
||||||
|
`.aria-debug/app-log-<timestamp>.json`.
|
||||||
|
|
||||||
|
Workflow nach einem Crash:
|
||||||
|
|
||||||
|
1. App rebuilden mit Crash-Reporting (passiert automatisch ab dem
|
||||||
|
`21a315c`-Commit)
|
||||||
|
2. Crash in der App ausloesen
|
||||||
|
3. `tools/fetch-app-logs.sh` auf der Dev-Maschine
|
||||||
|
4. Stacktrace lesen / Claude geben
|
||||||
|
5. Fix bauen
|
||||||
|
6. `tools/fetch-app-logs.sh --clear` damit der Log wieder sauber ist
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fetch-app-logs.sh — App-Crash-Logs von der VM holen
|
||||||
|
#
|
||||||
|
# Nutzt .claude/aria-vm.env als Quelle fuer $ARIA_DIAG_URL und ruft
|
||||||
|
# GET /api/app-log?limit=N. Speichert die Roh-Response unter
|
||||||
|
# .aria-debug/app-log-<timestamp>.json und gibt eine kompakte
|
||||||
|
# Zusammenfassung auf stdout aus (letzte Eintraege mit Stack-Trace).
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# tools/fetch-app-logs.sh # Default limit=200
|
||||||
|
# tools/fetch-app-logs.sh --limit 50 # nur 50 holen
|
||||||
|
# tools/fetch-app-logs.sh --clear # nach Abholen Log loeschen
|
||||||
|
# tools/fetch-app-logs.sh --watch # alle 5s pollen, neue Eintraege ausgeben
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LIMIT=200
|
||||||
|
CLEAR=0
|
||||||
|
WATCH=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--limit) LIMIT="$2"; shift 2 ;;
|
||||||
|
--limit=*) LIMIT="${1#*=}"; shift ;;
|
||||||
|
--clear) CLEAR=1; shift ;;
|
||||||
|
--watch) WATCH=1; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '1,/^set/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "Unbekannte Option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="$ROOT/.claude/aria-vm.env"
|
||||||
|
OUT_DIR="$ROOT/.aria-debug"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "FEHLER: $ENV_FILE nicht vorhanden. Aus .example kopieren und IP anpassen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
|
||||||
|
if [[ -z "${ARIA_DIAG_URL:-}" ]]; then
|
||||||
|
echo "FEHLER: ARIA_DIAG_URL nicht gesetzt in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
fetch_once() {
|
||||||
|
local ts json file
|
||||||
|
ts="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
json="$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT")" || {
|
||||||
|
echo "FEHLER: curl gegen $ARIA_DIAG_URL fehlgeschlagen" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
file="$OUT_DIR/app-log-$ts.json"
|
||||||
|
echo "$json" > "$file"
|
||||||
|
python3 - "$file" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text())
|
||||||
|
entries = data.get("entries") or []
|
||||||
|
print(f"=== {len(entries)} Eintrag{'e' if len(entries)!=1 else ''} (gespeichert unter {sys.argv[1]}) ===")
|
||||||
|
for e in entries[-20:]:
|
||||||
|
ts = e.get("ts") or 0
|
||||||
|
from datetime import datetime
|
||||||
|
when = datetime.fromtimestamp(ts/1000).strftime("%H:%M:%S") if ts else "?"
|
||||||
|
lvl = e.get("level","?")
|
||||||
|
scope = e.get("scope","?")
|
||||||
|
msg = (e.get("message") or "").splitlines()[0][:200]
|
||||||
|
print(f"\n[{when}] {lvl:5} {scope}: {msg}")
|
||||||
|
stack = (e.get("stack") or "").strip()
|
||||||
|
if stack:
|
||||||
|
for line in stack.splitlines()[:8]:
|
||||||
|
print(f" {line}")
|
||||||
|
if len(stack.splitlines()) > 8:
|
||||||
|
print(f" ... ({len(stack.splitlines())-8} weitere Zeilen — siehe JSON)")
|
||||||
|
PY
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$WATCH" == "1" ]]; then
|
||||||
|
echo "Watching $ARIA_DIAG_URL/api/app-log — Ctrl+C zum Beenden"
|
||||||
|
SEEN=""
|
||||||
|
while true; do
|
||||||
|
cur=$(curl -s --max-time 10 "${ARIA_DIAG_URL%/}/api/app-log?limit=$LIMIT") || cur=""
|
||||||
|
hash=$(echo "$cur" | md5sum | awk '{print $1}')
|
||||||
|
if [[ "$hash" != "$SEEN" && -n "$cur" ]]; then
|
||||||
|
SEEN="$hash"
|
||||||
|
fetch_once
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
else
|
||||||
|
fetch_once
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CLEAR" == "1" ]]; then
|
||||||
|
echo
|
||||||
|
echo "→ Log auf der VM leeren..."
|
||||||
|
curl -s --max-time 5 -X POST "${ARIA_DIAG_URL%/}/api/app-log/clear" | python3 -m json.tool || true
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user