Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed | |||
| 3cf6308b79 | |||
| 7e5a4da659 | |||
| d27fcaf342 | |||
| 5b28a065c0 | |||
| e74e1eaf70 | |||
| ff7c6333bb | |||
| 2c85df3499 | |||
| 6f11f28448 | |||
| 21a315ca71 | |||
| d8b05082d6 | |||
| de91073b2e | |||
| e88b5f57bf | |||
| 64a17c8c19 | |||
| ebeacba8b5 | |||
| 58251b26a2 | |||
| 5c10990cbc | |||
| f71936da86 | |||
| 62f394b2aa | |||
| 6239037fa7 | |||
| 4b3f8cded2 | |||
| 16ebaa652f | |||
| 27c04a2874 | |||
| 31a1370050 | |||
| 933dd50367 | |||
| d5531521fa | |||
| de9b7b46f9 | |||
| da4e970a31 | |||
| c677cfed24 | |||
| 331c1437be | |||
| 1e754910ee | |||
| 351c58e88e | |||
| df60bb6d74 | |||
| 24cf40293a | |||
| 5f96ace469 | |||
| 9dd95709b9 | |||
| a2dee3164a | |||
| 01f0ad3a40 | |||
| 6549fcbce8 |
@@ -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
|
||||||
|
|
||||||
@@ -242,7 +242,8 @@ Danach wird der Proxy gepatcht:
|
|||||||
| `aria-data/ssh/` | SSH-Key fuer den Zugriff auf aria-wohnung (Brain + Proxy teilen den Key) |
|
| `aria-data/ssh/` | SSH-Key fuer den Zugriff auf aria-wohnung (Brain + Proxy teilen den Key) |
|
||||||
| `aria-data/brain/qdrant/` | Vector-DB-Storage (Bind-Mount, gitignored) |
|
| `aria-data/brain/qdrant/` | Vector-DB-Storage (Bind-Mount, gitignored) |
|
||||||
| `aria-data/brain/data/` | Skills, Embedding-Modell-Cache (Bind-Mount, gitignored) |
|
| `aria-data/brain/data/` | Skills, Embedding-Modell-Cache (Bind-Mount, gitignored) |
|
||||||
| `aria-data/brain-import/` | `AGENT.md`, `USER.md.example`, `TOOLING.md.example` — Quelle fuer den initialen Memory-Import in die Vector-DB |
|
| `aria-data/brain-import/` | **Drop-Folder** fuer Markdown-Saatgut. Inhalt komplett gitignored ausser `.gitkeep` + `README.md`. Stefan kippt MDs rein wenn er was migrieren will, klickt Diagnostic-„Migration aus brain-import/" — sonst leer. DB ist Truth, brain-import nur Cold-Start-Schleuse |
|
||||||
|
| `.claude/aria-vm.env` | **Lokal pro Dev-Maschine** — wie erreicht die Workstation die VM (IP/Hostname). Gitignored, `.example` als Vorlage. Wird genutzt fuer direktes `curl` gegen die Brain-API von ausserhalb der VM |
|
||||||
| `aria-data/config/diag-state/` | Diagnostic State (z.B. zuletzt aktive Session) |
|
| `aria-data/config/diag-state/` | Diagnostic State (z.B. zuletzt aktive Session) |
|
||||||
|
|
||||||
### /shared/config/ (im aria-shared Volume)
|
### /shared/config/ (im aria-shared Volume)
|
||||||
@@ -316,9 +317,16 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
### Tabs
|
### Tabs
|
||||||
|
|
||||||
- **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 + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons (ℹ) ueberall mit Modal-Erklaerung.
|
- **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
|
||||||
|
|
||||||
@@ -355,6 +363,10 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
||||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
- **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
|
||||||
|
- **🗂️ 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
|
||||||
@@ -865,8 +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] **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 10208
|
versionCode 10400
|
||||||
versionName "0.1.2.8"
|
versionName "0.1.4.0"
|
||||||
// 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.2.8",
|
"version": "0.1.4.0",
|
||||||
"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;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Memory-Browser — Liste mit Suche + Filter, Tap oeffnet MemoryDetailModal.
|
||||||
|
*
|
||||||
|
* Eingesetzt von:
|
||||||
|
* - SettingsScreen → Sektion "Gedächtnis" (kompletter Editor)
|
||||||
|
* - Inbox-Modal (Notizen-Button neben Lupe) — kann aber auch Bubbles
|
||||||
|
* aus dem Chat als zusaetzlichen Filter zeigen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import brainApi, { Memory } from '../services/brainApi';
|
||||||
|
import MemoryDetailModal from './MemoryDetailModal';
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
identity: 'Identität', rule: 'Regeln', preference: 'Präferenzen',
|
||||||
|
tool: 'Tools', skill: 'Skills', fact: 'Fakten',
|
||||||
|
conversation: 'Konversation', reminder: 'Reminder',
|
||||||
|
};
|
||||||
|
const TYPE_OPTIONS = ['', 'identity', 'rule', 'preference', 'tool', 'skill', 'fact', 'conversation', 'reminder'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Wenn gesetzt: nur diese IDs anzeigen (z.B. Inbox-Modal mit Chat-Bubbles-Filter). */
|
||||||
|
restrictToIds?: string[];
|
||||||
|
/** Headline ueber der Liste. */
|
||||||
|
title?: string;
|
||||||
|
/** Style-Erweiterung fuer den Container. */
|
||||||
|
flatStyle?: boolean;
|
||||||
|
/** Wenn gesetzt: kein eigenes DetailModal mounten — Parent kuemmert sich. */
|
||||||
|
onOpenMemory?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle, onOpenMemory }) => {
|
||||||
|
const [items, setItems] = useState<Memory[]>([]);
|
||||||
|
const [filtered, setFiltered] = useState<Memory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const [pinnedFilter, setPinnedFilter] = useState<'all' | 'pinned' | 'cold'>('all');
|
||||||
|
const [showTypeMenu, setShowTypeMenu] = useState(false);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
brainApi.listMemories({ limit: 500 })
|
||||||
|
.then(setItems)
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// Filter clientseitig — bei kleiner DB (<1000) easy
|
||||||
|
useEffect(() => {
|
||||||
|
let out = items;
|
||||||
|
if (restrictToIds && restrictToIds.length) {
|
||||||
|
const set = new Set(restrictToIds);
|
||||||
|
out = out.filter(m => set.has(m.id));
|
||||||
|
}
|
||||||
|
if (typeFilter) out = out.filter(m => m.type === typeFilter);
|
||||||
|
if (pinnedFilter === 'pinned') out = out.filter(m => m.pinned);
|
||||||
|
else if (pinnedFilter === 'cold') out = out.filter(m => !m.pinned);
|
||||||
|
if (q.trim()) {
|
||||||
|
const needle = q.toLowerCase();
|
||||||
|
out = out.filter(m =>
|
||||||
|
(m.title || '').toLowerCase().includes(needle) ||
|
||||||
|
(m.content || '').toLowerCase().includes(needle) ||
|
||||||
|
(m.category || '').toLowerCase().includes(needle) ||
|
||||||
|
(m.tags || []).some(t => t.toLowerCase().includes(needle))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setFiltered(out);
|
||||||
|
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
|
||||||
|
|
||||||
|
const [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false);
|
||||||
|
const [newMemoryTitle, setNewMemoryTitle] = useState('');
|
||||||
|
|
||||||
|
const onAddNew = () => {
|
||||||
|
setNewMemoryTitle('');
|
||||||
|
setShowNewMemoryDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAddNew = async () => {
|
||||||
|
const t = newMemoryTitle.trim();
|
||||||
|
if (!t) { setShowNewMemoryDialog(false); return; }
|
||||||
|
setShowNewMemoryDialog(false);
|
||||||
|
try {
|
||||||
|
const m = await brainApi.saveMemory({
|
||||||
|
type: 'fact', title: t,
|
||||||
|
content: '(noch leer — bitte editieren)',
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
if (onOpenMemory) onOpenMemory(m.id);
|
||||||
|
else setOpenId(m.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Fehler', String(e?.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Memory }) => {
|
||||||
|
const attCount = (item.attachments || []).length;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={s.row} onPress={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<Text style={s.rowTitle} numberOfLines={1}>
|
||||||
|
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
|
||||||
|
{attCount > 0 ? <Text style={s.attBadge}>{` 📎${attCount}`}</Text> : null}
|
||||||
|
</Text>
|
||||||
|
<Text style={s.rowMeta} numberOfLines={1}>
|
||||||
|
{TYPE_LABELS[item.type] || item.type}
|
||||||
|
{item.category ? ` · [${item.category}]` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={s.rowPreview} numberOfLines={2}>{item.content}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[s.container, flatStyle && {padding:0,backgroundColor:'transparent'}]}>
|
||||||
|
{title ? <Text style={s.heading}>{title}</Text> : null}
|
||||||
|
|
||||||
|
<View style={s.searchRow}>
|
||||||
|
<TextInput
|
||||||
|
style={s.search}
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
placeholder="Suche in Titel, Inhalt, Tags…"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
/>
|
||||||
|
<TouchableOpacity style={s.iconBtn} onPress={load}>
|
||||||
|
<Text style={{color:'#0096FF'}}>↻</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={s.filterRow}>
|
||||||
|
<TouchableOpacity style={s.filterBtn} onPress={() => setShowTypeMenu(true)}>
|
||||||
|
<Text style={s.filterText}>{typeFilter ? (TYPE_LABELS[typeFilter] || typeFilter) : 'Alle Typen'} ▾</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={s.filterBtn} onPress={() => {
|
||||||
|
setPinnedFilter(pinnedFilter === 'all' ? 'pinned' : pinnedFilter === 'pinned' ? 'cold' : 'all');
|
||||||
|
}}>
|
||||||
|
<Text style={s.filterText}>
|
||||||
|
{pinnedFilter === 'pinned' ? '📌 Nur Pinned' : pinnedFilter === 'cold' ? 'Nur Cold' : 'Alle'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[s.filterBtn,{backgroundColor:'#0096FF'}]} onPress={onAddNew}>
|
||||||
|
<Text style={[s.filterText,{color:'#fff'}]}>+ Neu</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop:20}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filtered}
|
||||||
|
keyExtractor={m => m.id}
|
||||||
|
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={
|
||||||
|
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
||||||
|
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{paddingBottom:20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={s.footer}>
|
||||||
|
{filtered.length}/{items.length} Memories
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Type-Filter-Auswahl */}
|
||||||
|
<Modal visible={showTypeMenu} transparent animationType="fade" onRequestClose={() => setShowTypeMenu(false)}>
|
||||||
|
<TouchableOpacity style={s.menuBack} activeOpacity={1} onPress={() => setShowTypeMenu(false)}>
|
||||||
|
<View style={s.menuBox}>
|
||||||
|
{TYPE_OPTIONS.map(t => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t || 'all'}
|
||||||
|
style={s.menuItem}
|
||||||
|
onPress={() => { setTypeFilter(t); setShowTypeMenu(false); }}
|
||||||
|
>
|
||||||
|
<Text style={s.menuItemText}>
|
||||||
|
{t ? (TYPE_LABELS[t] || t) : 'Alle Typen'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt
|
||||||
|
(vermeidet Modal-in-Modal-Stacking auf Android). */}
|
||||||
|
{!onOpenMemory && (
|
||||||
|
<MemoryDetailModal
|
||||||
|
memoryId={openId}
|
||||||
|
visible={!!openId}
|
||||||
|
onClose={() => { 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
container: { flex:1, padding:8, backgroundColor:'#0D0D1A' },
|
||||||
|
heading: { color:'#0096FF', fontWeight:'bold', fontSize:14, marginBottom:8 },
|
||||||
|
searchRow: { flexDirection:'row', gap:6, marginBottom:6 },
|
||||||
|
search: { flex:1, backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:6, fontSize:13 },
|
||||||
|
iconBtn: { paddingHorizontal:12, justifyContent:'center', backgroundColor:'#1E1E2E', borderRadius:6 },
|
||||||
|
filterRow: { flexDirection:'row', gap:6, marginBottom:8 },
|
||||||
|
filterBtn: { backgroundColor:'#1E1E2E', paddingHorizontal:10, paddingVertical:6, borderRadius:6 },
|
||||||
|
filterText: { color:'#E0E0F0', fontSize:12 },
|
||||||
|
err: { color:'#FF6B6B', fontSize:12, marginVertical:6 },
|
||||||
|
row: { backgroundColor:'#1E1E2E', padding:10, borderRadius:6, marginBottom:6 },
|
||||||
|
rowTitle: { color:'#E0E0F0', fontWeight:'600', fontSize:13 },
|
||||||
|
attBadge: { color:'#34C759', fontWeight:'normal', fontSize:11 },
|
||||||
|
rowMeta: { color:'#8888AA', fontSize:11, marginTop:2 },
|
||||||
|
rowPreview: { color:'#666680', fontSize:11, marginTop:4 },
|
||||||
|
footer: { color:'#555570', fontSize:10, textAlign:'center', paddingVertical:6 },
|
||||||
|
menuBack: { flex:1, backgroundColor:'rgba(0,0,0,0.7)', justifyContent:'center', alignItems:'center' },
|
||||||
|
menuBox: { backgroundColor:'#0D0D1A', borderRadius:8, paddingVertical:4, minWidth:200 },
|
||||||
|
menuItem: { paddingVertical:10, paddingHorizontal:14 },
|
||||||
|
menuItemText: { color:'#E0E0F0', fontSize:13 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MemoryBrowser;
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* Memory-Detail-Modal — Anzeige + Edit eines einzelnen Memory-Eintrags.
|
||||||
|
*
|
||||||
|
* Zwei Modi:
|
||||||
|
* - read-only: zeigt alle Felder + Anhang-Vorschau (Klick auf Bild = Vollbild)
|
||||||
|
* - edit: Form mit Save/Delete/Anhang-hochladen
|
||||||
|
*
|
||||||
|
* Memory-Daten werden beim Oeffnen aus dem Brain (via brainApi → RVS) frisch
|
||||||
|
* gezogen. Optimistic Updates sind explizit nicht da — der DB-Stand ist die
|
||||||
|
* Truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker';
|
||||||
|
import RNFS from 'react-native-fs';
|
||||||
|
|
||||||
|
import brainApi, { Memory, MemoryAttachment } from '../services/brainApi';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
memoryId: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 'identity', label: 'identity (FEST)' },
|
||||||
|
{ value: 'rule', label: 'rule (FEST)' },
|
||||||
|
{ value: 'preference', label: 'preference (FEST)' },
|
||||||
|
{ value: 'tool', label: 'tool (FEST)' },
|
||||||
|
{ value: 'skill', label: 'skill (FEST)' },
|
||||||
|
{ value: 'fact', label: 'fact (Cold)' },
|
||||||
|
{ value: 'conversation', label: 'conversation (Cold)' },
|
||||||
|
{ value: 'reminder', label: 'reminder (Cold)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MemoryDetailModal: React.FC<Props> = ({ memoryId, visible, onClose, onDeleted }) => {
|
||||||
|
const [memory, setMemory] = useState<Memory | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit-Felder
|
||||||
|
const [eTitle, setETitle] = useState('');
|
||||||
|
const [eContent, setEContent] = useState('');
|
||||||
|
const [eCategory, setECategory] = useState('');
|
||||||
|
const [eTags, setETags] = useState('');
|
||||||
|
const [ePinned, setEPinned] = useState(false);
|
||||||
|
|
||||||
|
// Bild-Vollbild
|
||||||
|
const [fullscreen, setFullscreen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Memory laden beim Oeffnen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !memoryId) {
|
||||||
|
setMemory(null); setEditing(false); setErr(null); return;
|
||||||
|
}
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
brainApi.getMemory(memoryId)
|
||||||
|
.then(m => {
|
||||||
|
setMemory(m);
|
||||||
|
setETitle(m.title || '');
|
||||||
|
setEContent(m.content || '');
|
||||||
|
setECategory(m.category || '');
|
||||||
|
setETags((m.tags || []).join(', '));
|
||||||
|
setEPinned(!!m.pinned);
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [visible, memoryId]);
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
if (!memoryId) return;
|
||||||
|
setLoading(true);
|
||||||
|
brainApi.getMemory(memoryId)
|
||||||
|
.then(m => setMemory(m))
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!memoryId) return;
|
||||||
|
setSaving(true); setErr(null);
|
||||||
|
try {
|
||||||
|
const tags = eTags.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const m = await brainApi.updateMemory(memoryId, {
|
||||||
|
title: eTitle.trim(),
|
||||||
|
content: eContent.trim(),
|
||||||
|
category: eCategory.trim(),
|
||||||
|
tags,
|
||||||
|
pinned: ePinned,
|
||||||
|
});
|
||||||
|
setMemory(m);
|
||||||
|
setEditing(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
if (!memoryId || !memory) return;
|
||||||
|
Alert.alert(
|
||||||
|
'Memory loeschen?',
|
||||||
|
`"${memory.title}"\n\nWird permanent aus der DB entfernt, inkl. aller Anhaenge.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loeschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await brainApi.deleteMemory(memoryId);
|
||||||
|
if (onDeleted) onDeleted(memoryId);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Fehler', String(e?.message || e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickAndUpload = async () => {
|
||||||
|
if (!memoryId) return;
|
||||||
|
try {
|
||||||
|
const picked: DocumentPickerResponse[] = await DocumentPicker.pick({
|
||||||
|
type: [DocumentPicker.types.images, DocumentPicker.types.pdf, DocumentPicker.types.allFiles],
|
||||||
|
copyTo: 'cachesDirectory',
|
||||||
|
});
|
||||||
|
for (const f of picked) {
|
||||||
|
setBusy(`Lade ${f.name}…`);
|
||||||
|
// RNFS lesen → base64 → API
|
||||||
|
const localPath = (f.fileCopyUri || f.uri).replace(/^file:\/\//, '');
|
||||||
|
const b64 = await RNFS.readFile(localPath, 'base64');
|
||||||
|
await brainApi.uploadAttachment(memoryId, f.name || 'datei', b64);
|
||||||
|
}
|
||||||
|
setBusy(null);
|
||||||
|
reload();
|
||||||
|
} catch (e: any) {
|
||||||
|
setBusy(null);
|
||||||
|
if (DocumentPicker.isCancel(e)) return;
|
||||||
|
Alert.alert('Upload-Fehler', String(e?.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteAttachment = (att: MemoryAttachment) => {
|
||||||
|
if (!memoryId) return;
|
||||||
|
Alert.alert(
|
||||||
|
'Anhang loeschen?',
|
||||||
|
`"${att.name}"`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loeschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const m = await brainApi.deleteAttachment(memoryId, att.name);
|
||||||
|
setMemory(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Fehler', String(e?.message || e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTapAttachment = async (att: MemoryAttachment) => {
|
||||||
|
if (!memoryId) return;
|
||||||
|
if ((att.mime || '').startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
setBusy('Lade Bild…');
|
||||||
|
const data = await brainApi.getAttachmentBytes(memoryId, att.name);
|
||||||
|
// Temp-File schreiben damit <Image source={uri: file://...}> es zeigen kann
|
||||||
|
const safe = att.name.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||||
|
const localPath = `${RNFS.CachesDirectoryPath}/memory_${memoryId}_${safe}`;
|
||||||
|
await RNFS.writeFile(localPath, data.base64, 'base64');
|
||||||
|
setBusy(null);
|
||||||
|
setFullscreen('file://' + localPath);
|
||||||
|
} catch (e: any) {
|
||||||
|
setBusy(null);
|
||||||
|
Alert.alert('Fehler', String(e?.message || e));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert('Anhang', `${att.name}\n${att.mime}\n${att.size} Byte\n\nPfad: ${att.path}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||||
|
<View style={s.backdrop}>
|
||||||
|
<View style={s.box}>
|
||||||
|
<View style={s.header}>
|
||||||
|
<Text style={s.title}>{editing ? 'Memory bearbeiten' : 'Memory-Detail'}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={s.closeX}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={s.body} contentContainerStyle={{paddingBottom:20}}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop:30}} />
|
||||||
|
) : err && !memory ? (
|
||||||
|
<Text style={s.err}>{err}</Text>
|
||||||
|
) : memory ? (
|
||||||
|
editing ? (
|
||||||
|
<View>
|
||||||
|
<Text style={s.label}>Typ</Text>
|
||||||
|
<Text style={{color:'#888',fontSize:12,marginBottom:8}}>{memory.type} (kann hier nicht geaendert werden)</Text>
|
||||||
|
|
||||||
|
<Text style={s.label}>Titel</Text>
|
||||||
|
<TextInput style={s.input} value={eTitle} onChangeText={setETitle} />
|
||||||
|
|
||||||
|
<Text style={s.label}>Inhalt</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {minHeight:120, textAlignVertical:'top'}]}
|
||||||
|
value={eContent}
|
||||||
|
onChangeText={setEContent}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>Kategorie</Text>
|
||||||
|
<TextInput style={s.input} value={eCategory} onChangeText={setECategory} />
|
||||||
|
|
||||||
|
<Text style={s.label}>Tags (komma-getrennt)</Text>
|
||||||
|
<TextInput style={s.input} value={eTags} onChangeText={setETags} />
|
||||||
|
|
||||||
|
<View style={{flexDirection:'row',alignItems:'center',marginTop:10,gap:8}}>
|
||||||
|
<Switch value={ePinned} onValueChange={setEPinned} />
|
||||||
|
<Text style={{color:'#E0E0F0'}}>📌 Pinned (immer im System-Prompt)</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
<View style={{flexDirection:'row',gap:8,marginTop:14}}>
|
||||||
|
<TouchableOpacity style={[s.btn,s.btnSecondary]} onPress={() => setEditing(false)} disabled={saving}>
|
||||||
|
<Text style={s.btnText}>Abbrechen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[s.btn,s.btnPrimary,{flex:1}]} onPress={onSave} disabled={saving}>
|
||||||
|
<Text style={s.btnText}>{saving ? 'Speichere…' : 'Speichern'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<View style={{flexDirection:'row',alignItems:'flex-start',justifyContent:'space-between'}}>
|
||||||
|
<Text style={s.bigTitle}>{memory.pinned ? '📌 ' : ''}{memory.title}</Text>
|
||||||
|
<TouchableOpacity onPress={() => setEditing(true)} style={s.iconBtn}>
|
||||||
|
<Text style={s.iconBtnText}>✎</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={s.meta}>
|
||||||
|
{memory.type}{memory.category ? ` · [${memory.category}]` : ''}
|
||||||
|
</Text>
|
||||||
|
{(memory.tags || []).length > 0 ? (
|
||||||
|
<View style={s.tagsRow}>
|
||||||
|
{memory.tags.map(t => <Text key={t} style={s.tag}>{t}</Text>)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text style={s.contentBlock}>{memory.content}</Text>
|
||||||
|
|
||||||
|
<Text style={s.sectionHead}>📎 Anhaenge</Text>
|
||||||
|
{(memory.attachments || []).length === 0 ? (
|
||||||
|
<Text style={{color:'#555570',fontStyle:'italic',fontSize:12}}>(keine)</Text>
|
||||||
|
) : (
|
||||||
|
(memory.attachments || []).map((a) => {
|
||||||
|
const isImage = (a.mime || '').startsWith('image/');
|
||||||
|
return (
|
||||||
|
<View key={a.name} style={s.attRow}>
|
||||||
|
<TouchableOpacity style={{flexDirection:'row',alignItems:'center',gap:8,flex:1}} onPress={() => onTapAttachment(a)}>
|
||||||
|
<Text style={{fontSize:18}}>{isImage ? '🖼️' : '📄'}</Text>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<Text style={{color:'#E0E0F0',fontSize:12}} numberOfLines={1}>{a.name}</Text>
|
||||||
|
<Text style={{color:'#555570',fontSize:10}}>{a.mime} · {Math.round(a.size/1024)} KB</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => onDeleteAttachment(a)} style={s.attDelete}>
|
||||||
|
<Text style={{color:'#FF6B6B',fontSize:12}}>🗑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={[s.btn,s.btnSecondary,{marginTop:8}]} onPress={onPickAndUpload}>
|
||||||
|
<Text style={s.btnText}>⬆ Datei anhaengen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{busy ? <Text style={{color:'#8888AA',fontSize:11,marginTop:4}}>{busy}</Text> : null}
|
||||||
|
|
||||||
|
<Text style={s.timestamps}>
|
||||||
|
angelegt: {(memory.created_at || '').slice(0,16).replace('T',' ')}{'\n'}
|
||||||
|
geaendert: {(memory.updated_at || '').slice(0,16).replace('T',' ')}{'\n'}
|
||||||
|
id: {memory.id}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={[s.btn,s.btnDanger,{marginTop:14}]} onPress={onDelete}>
|
||||||
|
<Text style={s.btnText}>🗑 Memory komplett loeschen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal visible={!!fullscreen} transparent onRequestClose={() => setFullscreen(null)}>
|
||||||
|
<TouchableOpacity style={s.fsBack} onPress={() => setFullscreen(null)}>
|
||||||
|
{fullscreen ? <Image source={{uri:fullscreen}} style={s.fsImg} resizeMode="contain" /> : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
backdrop: { flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'flex-end' },
|
||||||
|
box: { backgroundColor:'#0D0D1A', borderTopLeftRadius:12, borderTopRightRadius:12, maxHeight:'92%' },
|
||||||
|
header: { flexDirection:'row', justifyContent:'space-between', alignItems:'center', padding:14, borderBottomColor:'#1E1E2E', borderBottomWidth:1 },
|
||||||
|
title: { color:'#FFD60A', fontWeight:'bold', fontSize:15 },
|
||||||
|
closeX: { color:'#8888AA', fontSize:24, paddingHorizontal:6 },
|
||||||
|
body: { padding:14 },
|
||||||
|
err: { color:'#FF6B6B', fontSize:12, marginTop:8 },
|
||||||
|
label: { color:'#8888AA', fontSize:11, marginBottom:3, marginTop:8 },
|
||||||
|
input: { backgroundColor:'#080810', borderColor:'#1E1E2E', borderWidth:1, borderRadius:4, padding:8, color:'#E0E0F0', fontSize:13 },
|
||||||
|
bigTitle: { color:'#E0E0F0', fontWeight:'bold', fontSize:16, flex:1, marginRight:6 },
|
||||||
|
iconBtn: { padding:6, backgroundColor:'#1E1E2E', borderRadius:6 },
|
||||||
|
iconBtnText: { color:'#0096FF', fontSize:14 },
|
||||||
|
meta: { color:'#8888AA', fontSize:11, marginTop:4 },
|
||||||
|
tagsRow: { flexDirection:'row', flexWrap:'wrap', gap:4, marginTop:6 },
|
||||||
|
tag: { backgroundColor:'#1E1E2E', color:'#8888AA', fontSize:10, paddingHorizontal:6, paddingVertical:2, borderRadius:8 },
|
||||||
|
contentBlock: { color:'#E0E0F0', fontSize:13, marginTop:12, lineHeight:18 },
|
||||||
|
sectionHead: { color:'#0096FF', fontSize:11, marginTop:14, marginBottom:6, textTransform:'uppercase', letterSpacing:0.5 },
|
||||||
|
attRow: { flexDirection:'row', alignItems:'center', backgroundColor:'#080810', padding:8, borderRadius:6, marginBottom:4, gap:6 },
|
||||||
|
attDelete: { padding:4 },
|
||||||
|
timestamps: { color:'#555570', fontSize:10, marginTop:12, fontFamily:'monospace' },
|
||||||
|
btn: { paddingVertical:10, paddingHorizontal:14, borderRadius:6, alignItems:'center' },
|
||||||
|
btnPrimary: { backgroundColor:'#0096FF' },
|
||||||
|
btnSecondary: { backgroundColor:'#1E1E2E' },
|
||||||
|
btnDanger: { backgroundColor:'#3B1010', borderWidth:1, borderColor:'#FF6B6B' },
|
||||||
|
btnText: { color:'#fff', fontSize:13, fontWeight:'600' },
|
||||||
|
fsBack: { flex:1, backgroundColor:'rgba(0,0,0,0.95)', justifyContent:'center', alignItems:'center' },
|
||||||
|
fsImg: { width:'95%', height:'85%' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MemoryDetailModal;
|
||||||
@@ -28,6 +28,9 @@ import RNFS from 'react-native-fs';
|
|||||||
import { SvgUri } from 'react-native-svg';
|
import { SvgUri } from 'react-native-svg';
|
||||||
import { Dimensions } from 'react-native';
|
import { Dimensions } from 'react-native';
|
||||||
import ZoomableImage from '../components/ZoomableImage';
|
import ZoomableImage from '../components/ZoomableImage';
|
||||||
|
import MemoryDetailModal from '../components/MemoryDetailModal';
|
||||||
|
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';
|
||||||
@@ -87,6 +90,25 @@ interface ChatMessage {
|
|||||||
fires_at?: string;
|
fires_at?: string;
|
||||||
condition?: string;
|
condition?: string;
|
||||||
};
|
};
|
||||||
|
/** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */
|
||||||
|
memorySaved?: {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
category?: string;
|
||||||
|
pinned: boolean;
|
||||||
|
preview?: string;
|
||||||
|
/** Was passiert ist: angelegt / geaendert / geloescht. Default created
|
||||||
|
* fuer Rueckwaerts-Kompatibilitaet mit aelteren Events. */
|
||||||
|
action?: 'created' | 'updated' | 'deleted';
|
||||||
|
attachments?: Array<{
|
||||||
|
name: string;
|
||||||
|
mime?: string;
|
||||||
|
size?: number;
|
||||||
|
path?: string; // Server-Pfad /shared/memory-attachments/<id>/<name>
|
||||||
|
localUri?: string; // Nach file_request gefuelltes file://-URI
|
||||||
|
}>;
|
||||||
|
};
|
||||||
/** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung
|
/** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung
|
||||||
* zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
|
* zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
|
||||||
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
||||||
@@ -212,6 +234,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
// Genauer State (off/armed/conversing) fuer UI-Feedback am Button
|
||||||
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
|
const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off');
|
||||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||||
|
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
||||||
|
const [inboxVisible, setInboxVisible] = 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
|
||||||
@@ -467,6 +491,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const localOnly = prev.filter(m =>
|
const localOnly = prev.filter(m =>
|
||||||
m.skillCreated ||
|
m.skillCreated ||
|
||||||
m.triggerCreated ||
|
m.triggerCreated ||
|
||||||
|
m.memorySaved ||
|
||||||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
||||||
);
|
);
|
||||||
// Server-Stand + lokal-only (chronologisch sortiert)
|
// Server-Stand + lokal-only (chronologisch sortiert)
|
||||||
@@ -521,6 +546,36 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memory_saved: ARIA hat etwas via memory_save Tool in die Qdrant-DB
|
||||||
|
// gepackt — eigene Bubble (gelb wie trigger/skill).
|
||||||
|
if (message.type === 'memory_saved') {
|
||||||
|
const p = (message.payload || {}) as any;
|
||||||
|
const atts = Array.isArray(p.attachments) ? p.attachments.map((a: any) => ({
|
||||||
|
name: String(a?.name || 'datei'),
|
||||||
|
mime: a?.mime ? String(a.mime) : undefined,
|
||||||
|
size: typeof a?.size === 'number' ? a.size : undefined,
|
||||||
|
path: a?.path ? String(a.path) : undefined,
|
||||||
|
})) : [];
|
||||||
|
const memoryMsg: ChatMessage = {
|
||||||
|
id: nextId(),
|
||||||
|
sender: 'aria',
|
||||||
|
text: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
memorySaved: {
|
||||||
|
id: p.id ? String(p.id) : undefined,
|
||||||
|
title: String(p.title || '(ohne Titel)'),
|
||||||
|
type: String(p.type || 'fact'),
|
||||||
|
category: p.category ? String(p.category) : undefined,
|
||||||
|
pinned: !!p.pinned,
|
||||||
|
preview: p.content_preview ? String(p.content_preview) : undefined,
|
||||||
|
action: (p.action === 'updated' || p.action === 'deleted') ? p.action : 'created',
|
||||||
|
attachments: atts.length ? atts : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setMessages(prev => capMessages([...prev, memoryMsg]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
|
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
|
||||||
if (message.type === 'file_deleted') {
|
if (message.type === 'file_deleted') {
|
||||||
const p = (message.payload?.path as string) || '';
|
const p = (message.payload?.path as string) || '';
|
||||||
@@ -565,16 +620,38 @@ const ChatScreen: React.FC = () => {
|
|||||||
if (b64 && reqId) {
|
if (b64 && reqId) {
|
||||||
const fileName = (message.payload.name as string) || 'download';
|
const fileName = (message.payload.name as string) || 'download';
|
||||||
persistAttachment(b64, reqId, fileName).then(filePath => {
|
persistAttachment(b64, reqId, fileName).then(filePath => {
|
||||||
setMessages(prev => prev.map(m => ({
|
setMessages(prev => prev.map(m => {
|
||||||
...m,
|
// Hauptattachments updaten (Bilder/Files am User-Send / ARIA-File-Bubble)
|
||||||
attachments: m.attachments?.map(a =>
|
const updatedAtts = m.attachments?.map(a =>
|
||||||
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
a.serverPath === serverPath ? { ...a, uri: filePath } : a
|
||||||
),
|
);
|
||||||
})));
|
// Memory-Anhang-Match (Bubble vom memory_saved-Event)
|
||||||
|
const ms = m.memorySaved;
|
||||||
|
let updatedMs = ms;
|
||||||
|
if (ms && Array.isArray(ms.attachments)) {
|
||||||
|
const hit = ms.attachments.some(a => a.path === serverPath);
|
||||||
|
if (hit) {
|
||||||
|
updatedMs = {
|
||||||
|
...ms,
|
||||||
|
attachments: ms.attachments.map(a =>
|
||||||
|
a.path === serverPath ? { ...a, localUri: filePath } : a
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...m, attachments: updatedAtts, memorySaved: updatedMs };
|
||||||
|
}));
|
||||||
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
|
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
|
||||||
|
// (Bilder werden separat via setFullscreenImage in der memorySaved-
|
||||||
|
// Bubble geoeffnet, das laeuft nicht ueber autoOpenPaths)
|
||||||
if (serverPath && autoOpenPaths.current.has(serverPath)) {
|
if (serverPath && autoOpenPaths.current.has(serverPath)) {
|
||||||
autoOpenPaths.current.delete(serverPath);
|
autoOpenPaths.current.delete(serverPath);
|
||||||
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
|
const isImage = (mimeType || '').startsWith('image/');
|
||||||
|
if (isImage) {
|
||||||
|
setFullscreenImage(filePath);
|
||||||
|
} else {
|
||||||
|
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -950,7 +1027,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
|
||||||
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
|
// Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
|
||||||
|
// NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
|
||||||
|
// Das verhindert dass sie chronologisch unten im Chat haengen und der
|
||||||
|
// eigentliche Chat-Verlauf darunter verschwindet.
|
||||||
|
const chatVisibleMessages = useMemo(
|
||||||
|
() => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
|
||||||
|
|
||||||
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
|
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
|
||||||
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
||||||
@@ -1253,6 +1338,84 @@ const ChatScreen: React.FC = () => {
|
|||||||
? { borderWidth: 2, borderColor: '#FFD60A' }
|
? { borderWidth: 2, borderColor: '#FFD60A' }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Spezial-Bubble: ARIA hat etwas via memory_save gespeichert
|
||||||
|
if (item.memorySaved) {
|
||||||
|
const m = item.memorySaved;
|
||||||
|
const catPart = m.category ? ` · [${m.category}]` : '';
|
||||||
|
const atts = m.attachments || [];
|
||||||
|
const action = m.action || 'created';
|
||||||
|
const headline =
|
||||||
|
action === 'updated' ? '🧠 ARIA hat eine Notiz geändert' :
|
||||||
|
action === 'deleted' ? '🧠 ARIA hat eine Notiz gelöscht' :
|
||||||
|
'🧠 ARIA hat etwas gemerkt';
|
||||||
|
const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
||||||
|
const borderColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
||||||
|
const openable = !!m.id && action !== 'deleted';
|
||||||
|
const Wrapper: any = openable ? TouchableOpacity : View;
|
||||||
|
const wrapperProps = openable
|
||||||
|
? { onPress: () => setMemoryDetailId(m.id || null), activeOpacity: 0.7 }
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
|
<Wrapper {...wrapperProps} style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: borderColor}, searchHighlightStyle]}>
|
||||||
|
<Text style={{color: headlineColor, fontWeight: 'bold', fontSize: 14}}>
|
||||||
|
{headline}
|
||||||
|
</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
|
||||||
|
<Text style={{fontWeight: 'bold'}}>{m.title}</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`}</Text>
|
||||||
|
</Text>
|
||||||
|
{m.preview ? (
|
||||||
|
<Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
|
||||||
|
) : null}
|
||||||
|
{atts.map((a, idx) => {
|
||||||
|
const isImage = (a.mime || '').startsWith('image/');
|
||||||
|
const icon = isImage ? '🖼️' : '📄';
|
||||||
|
const sizeStr = a.size ? ` · ${(a.size / 1024).toFixed(0)} KB` : '';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`${item.id}-att-${idx}`}
|
||||||
|
style={styles.memoryAttachmentRow}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!a.path) return;
|
||||||
|
if (a.localUri) {
|
||||||
|
const localPath = a.localUri.replace(/^file:\/\//, '');
|
||||||
|
const exists = await RNFS.exists(localPath).catch(() => false);
|
||||||
|
if (exists) {
|
||||||
|
if (isImage) setFullscreenImage(a.localUri);
|
||||||
|
else openFileWithIntent(localPath, a.mime || '');
|
||||||
|
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
|
||||||
|
// 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.memoryAttachmentName} numberOfLines={1}>{a.name}</Text>
|
||||||
|
<Text style={styles.memoryAttachmentMeta}>
|
||||||
|
{a.localUri ? '(tippen zum oeffnen)' : `(tippen zum Laden${sizeStr})`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>
|
||||||
|
ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''}
|
||||||
|
</Text>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Spezial-Bubble: ARIA hat einen Trigger angelegt
|
// Spezial-Bubble: ARIA hat einen Trigger angelegt
|
||||||
if (item.triggerCreated) {
|
if (item.triggerCreated) {
|
||||||
const t = item.triggerCreated;
|
const t = item.triggerCreated;
|
||||||
@@ -1339,17 +1502,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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1455,7 +1633,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
{connectionState === 'connected' ? 'Verbunden' :
|
{connectionState === 'connected' ? 'Verbunden' :
|
||||||
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{marginLeft: 'auto', paddingHorizontal: 8}}>
|
<TouchableOpacity onPress={() => setInboxVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
||||||
|
<Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
|
||||||
<Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
|
<Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -1686,6 +1867,111 @@ const ChatScreen: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
|
||||||
|
{memoryDetailId ? (
|
||||||
|
<ErrorBoundary scope="ChatScreen.MemoryDetailModal" onReset={() => setMemoryDetailId(null)}>
|
||||||
|
<MemoryDetailModal
|
||||||
|
memoryId={memoryDetailId}
|
||||||
|
visible={!!memoryDetailId}
|
||||||
|
onClose={() => setMemoryDetailId(null)}
|
||||||
|
onDeleted={() => setMemoryDetailId(null)}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
|
||||||
|
Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
|
||||||
|
des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
|
||||||
|
<Modal visible={inboxVisible} animationType="slide" onRequestClose={() => setInboxVisible(false)}>
|
||||||
|
<ErrorBoundary scope="ChatScreen.InboxModal" onReset={() => setInboxVisible(false)}>
|
||||||
|
<View style={{flex:1, backgroundColor:'#0D0D1A'}}>
|
||||||
|
<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>
|
||||||
|
<TouchableOpacity onPress={() => setInboxVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={{color:'#8888AA', fontSize:24}}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{/* Aus aktuellem Chat: Spezial-Bubbles (memory/trigger/skill) kompakt
|
||||||
|
auflisten — neueste oben. Klick auf Memory oeffnet Detail-Modal. */}
|
||||||
|
{(() => {
|
||||||
|
const specials = messages
|
||||||
|
.filter(m => m.memorySaved || m.triggerCreated || m.skillCreated)
|
||||||
|
.slice().reverse();
|
||||||
|
if (specials.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={{padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
|
||||||
|
<Text style={{color:'#555570', fontSize:11, fontStyle:'italic'}}>
|
||||||
|
(keine Notizen-Bubbles im aktuellen Chat)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={{maxHeight:260, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
|
||||||
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
|
Aus diesem Chat
|
||||||
|
</Text>
|
||||||
|
<ScrollView style={{paddingHorizontal:8}}>
|
||||||
|
{specials.map(m => {
|
||||||
|
if (m.memorySaved) {
|
||||||
|
const ms = m.memorySaved;
|
||||||
|
const action = ms.action || 'created';
|
||||||
|
const verb = action === 'updated' ? 'geändert' : action === 'deleted' ? 'gelöscht' : 'angelegt';
|
||||||
|
const dotColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={m.id}
|
||||||
|
style={styles.inboxRow}
|
||||||
|
onPress={() => { if (ms.id && action !== 'deleted') { setInboxVisible(false); setMemoryDetailId(ms.id); } }}
|
||||||
|
disabled={!ms.id || action === 'deleted'}
|
||||||
|
>
|
||||||
|
<Text style={{fontSize:16}}>{'🧠'}</Text>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<Text style={styles.inboxRowTitle} numberOfLines={1}>{ms.title}</Text>
|
||||||
|
<Text style={[styles.inboxRowMeta, {color: dotColor}]}>Memory · {verb} · {ms.type}</Text>
|
||||||
|
</View>
|
||||||
|
{ms.id && action !== 'deleted' ? <Text style={{color:'#0096FF', fontSize:14}}>›</Text> : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (m.triggerCreated) {
|
||||||
|
const t = m.triggerCreated;
|
||||||
|
return (
|
||||||
|
<View key={m.id} style={styles.inboxRow}>
|
||||||
|
<Text style={{fontSize:16}}>{'⏰'}</Text>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<Text style={styles.inboxRowTitle} numberOfLines={1}>{t.name}</Text>
|
||||||
|
<Text style={styles.inboxRowMeta}>Trigger · {t.type}{t.fires_at ? ` · ${t.fires_at.slice(0,16).replace('T',' ')}` : ''}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (m.skillCreated) {
|
||||||
|
const sk = m.skillCreated;
|
||||||
|
return (
|
||||||
|
<View key={m.id} style={styles.inboxRow}>
|
||||||
|
<Text style={{fontSize:16}}>{'🛠'}</Text>
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<Text style={styles.inboxRowTitle} numberOfLines={1}>{sk.name}</Text>
|
||||||
|
<Text style={styles.inboxRowMeta}>Skill · {sk.execution}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
|
Alle Memories aus der DB
|
||||||
|
</Text>
|
||||||
|
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
||||||
|
</View>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Bild-Vollbild Modal */}
|
{/* Bild-Vollbild Modal */}
|
||||||
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
<Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
|
||||||
<View style={styles.fullscreenOverlay}>
|
<View style={styles.fullscreenOverlay}>
|
||||||
@@ -2014,6 +2300,47 @@ const styles = StyleSheet.create({
|
|||||||
playButtonText: {
|
playButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
|
inboxRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
inboxRowTitle: {
|
||||||
|
color: '#E0E0F0',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
inboxRowMeta: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
memoryAttachmentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
memoryAttachmentIcon: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
memoryAttachmentName: {
|
||||||
|
flex: 1,
|
||||||
|
color: '#E0E0F0',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
memoryAttachmentMeta: {
|
||||||
|
color: '#555570',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
bubbleTrash: {
|
bubbleTrash: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import gpsTrackingService from '../services/gpsTracking';
|
import gpsTrackingService from '../services/gpsTracking';
|
||||||
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -100,6 +101,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
{ id: 'voice_output', icon: '🔊', label: 'Sprachausgabe', desc: 'Stimmen, Pre-Roll, Geschwindigkeit' },
|
||||||
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
{ id: 'storage', icon: '📁', label: 'Speicher', desc: 'Anhang-Speicherort, Auto-Download' },
|
||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
||||||
|
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -866,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 && (
|
||||||
<>
|
<>
|
||||||
@@ -1673,6 +1675,18 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* === Gedaechtnis === */}
|
||||||
|
{currentSection === 'memory' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Gedächtnis</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||||
|
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
||||||
|
Tags. Neue Einträge anlegen via "+ Neu".
|
||||||
|
</Text>
|
||||||
|
<View style={{height: 600, marginBottom: 8}}>
|
||||||
|
<MemoryBrowser />
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Brain-API-Client fuer die App.
|
||||||
|
*
|
||||||
|
* Die App hat keinen direkten HTTP-Zugriff aufs Brain (nur via RVS). Wir
|
||||||
|
* tunneln alle Memory-Operationen ueber den generischen brain_request /
|
||||||
|
* brain_response RVS-Channel den die Bridge implementiert.
|
||||||
|
*
|
||||||
|
* Pattern: pro Call eine eindeutige requestId, Listener wartet auf passende
|
||||||
|
* brain_response, Promise loest auf / wird abgelehnt bei status>=400.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import rvs from './rvs';
|
||||||
|
|
||||||
|
type AnyJson = any;
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
resolve: (data: AnyJson) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
expectBinary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, PendingRequest>();
|
||||||
|
let installed = false;
|
||||||
|
|
||||||
|
function _ensureListener() {
|
||||||
|
if (installed) return;
|
||||||
|
installed = true;
|
||||||
|
rvs.onMessage((msg: any) => {
|
||||||
|
if (!msg || msg.type !== 'brain_response') return;
|
||||||
|
const p = msg.payload || {};
|
||||||
|
const reqId: string = p.requestId || '';
|
||||||
|
const handler = pending.get(reqId);
|
||||||
|
if (!handler) return;
|
||||||
|
pending.delete(reqId);
|
||||||
|
clearTimeout(handler.timer);
|
||||||
|
const status: number = Number(p.status || 0);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
if (handler.expectBinary) {
|
||||||
|
handler.resolve({ base64: p.base64 || '', contentType: p.contentType || '' });
|
||||||
|
} else {
|
||||||
|
handler.resolve(p.json !== undefined ? p.json : (p.text !== undefined ? p.text : null));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const detail = (p.json && p.json.detail) || p.text || `HTTP ${status}`;
|
||||||
|
handler.reject(new Error(`Brain ${status}: ${detail}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _nextId = 0;
|
||||||
|
function _newRequestId(): string {
|
||||||
|
_nextId += 1;
|
||||||
|
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 {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
|
body?: AnyJson;
|
||||||
|
bodyBase64?: string;
|
||||||
|
contentType?: string;
|
||||||
|
expectBinary?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
|
||||||
|
_ensureListener();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestId = _newRequestId();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (pending.delete(requestId)) {
|
||||||
|
reject(new Error(`Brain-Timeout fuer ${path}`));
|
||||||
|
}
|
||||||
|
}, opts.timeoutMs || 30000);
|
||||||
|
pending.set(requestId, { resolve, reject, timer, expectBinary: opts.expectBinary });
|
||||||
|
rvs.send('brain_request' as any, {
|
||||||
|
requestId,
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
path,
|
||||||
|
...(opts.body !== undefined ? { body: opts.body } : {}),
|
||||||
|
...(opts.bodyBase64 ? { bodyBase64: opts.bodyBase64 } : {}),
|
||||||
|
...(opts.contentType ? { contentType: opts.contentType } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Typen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MemoryAttachment {
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Memory {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
pinned: boolean;
|
||||||
|
category: string;
|
||||||
|
source: string;
|
||||||
|
tags: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
conversation_id?: string | null;
|
||||||
|
score?: number | null;
|
||||||
|
attachments?: MemoryAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const brainApi = {
|
||||||
|
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
|
||||||
|
getMemory(id: string): Promise<Memory> {
|
||||||
|
return _send(`/memory/get/${encodeURIComponent(id)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||||
|
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||||
|
const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
|
||||||
|
return _send(`/memory/list${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Volltext-Substring-Suche. */
|
||||||
|
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
||||||
|
const qs = _qs({
|
||||||
|
q,
|
||||||
|
type: opts.type,
|
||||||
|
include_pinned: opts.includePinned !== false,
|
||||||
|
k: opts.k || 50,
|
||||||
|
});
|
||||||
|
return _send(`/memory/search-text${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Semantische Suche (Embedder). */
|
||||||
|
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
||||||
|
const qs = _qs({
|
||||||
|
q,
|
||||||
|
type: opts.type,
|
||||||
|
include_pinned: opts.includePinned !== false,
|
||||||
|
k: opts.k || 10,
|
||||||
|
score_threshold: opts.threshold ?? 0.30,
|
||||||
|
});
|
||||||
|
return _send(`/memory/search${qs}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Memory anlegen. */
|
||||||
|
saveMemory(body: {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}): Promise<Memory> {
|
||||||
|
return _send('/memory/save', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { source: 'app', ...body },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Memory aktualisieren (Patch — nur uebergebene Felder werden geaendert). */
|
||||||
|
updateMemory(id: string, body: Partial<Pick<Memory, 'title' | 'content' | 'pinned' | 'category' | 'tags'>>): Promise<Memory> {
|
||||||
|
return _send(`/memory/update/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Memory loeschen. */
|
||||||
|
deleteMemory(id: string): Promise<{ deleted: string }> {
|
||||||
|
return _send(`/memory/delete/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Anhaenge ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Datei als Anhang an die Memory haengen (Base64-Upload). */
|
||||||
|
uploadAttachment(memoryId: string, name: string, base64: string): Promise<Memory> {
|
||||||
|
return _send(`/memory/${encodeURIComponent(memoryId)}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, data_base64: base64 },
|
||||||
|
timeoutMs: 120000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Anhang loeschen. */
|
||||||
|
deleteAttachment(memoryId: string, filename: string): Promise<Memory> {
|
||||||
|
return _send(
|
||||||
|
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Anhang-Bytes holen (fuer Vorschau / Download). Liefert Base64. */
|
||||||
|
getAttachmentBytes(memoryId: string, filename: string): Promise<{ base64: string; contentType: string }> {
|
||||||
|
return _send(
|
||||||
|
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||||
|
{ expectBinary: true, timeoutMs: 60000 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default brainApi;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+299
-4
@@ -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",
|
||||||
@@ -206,6 +215,126 @@ META_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "memory_search",
|
||||||
|
"description": (
|
||||||
|
"Durchsuche aktiv dein Gedaechtnis (Qdrant-DB). Nutze das wenn:\n"
|
||||||
|
"- der User sagt 'schau in deinem Gedaechtnis' / 'ich hab das Memory aktualisiert'\n"
|
||||||
|
"- du dir bei einer Info aus dem Konversations-Verlauf unsicher bist "
|
||||||
|
"(z.B. ob das noch der aktuelle Stand ist)\n"
|
||||||
|
"- du pruefen willst ob's schon einen Memory zu einem Thema gibt bevor "
|
||||||
|
"du via memory_save einen neuen anlegst (vermeidet Fragmentierung)\n\n"
|
||||||
|
"**WICHTIG: Memory ist Truth ueber dem Conversation-Window.** "
|
||||||
|
"Wenn dort was anders steht als in deinem Gespraechs-Verlauf, gilt das "
|
||||||
|
"was im Memory steht — der User koennte gerade was korrigiert haben.\n\n"
|
||||||
|
"Mode 'text' = Substring (case-insensitive), gut fuer exakte Begriffe "
|
||||||
|
"wie 'cessna'. Mode 'semantic' = Embedder-Search, gut fuer 'wann hatten "
|
||||||
|
"wir ueber X gesprochen'-Fragen."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Such-Begriff"},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["text", "semantic"],
|
||||||
|
"description": "Default 'text' (Substring). 'semantic' fuer aehnlichkeits-Suche.",
|
||||||
|
},
|
||||||
|
"k": {"type": "integer", "description": "Wieviele Treffer (Default 5, max 20)"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "memory_update",
|
||||||
|
"description": (
|
||||||
|
"Aktualisiere einen existierenden Memory-Eintrag — gibt die ID aus "
|
||||||
|
"memory_search oder dem Cold-Memory an. Nur die uebergebenen Felder werden "
|
||||||
|
"ueberschrieben, der Rest bleibt unangetastet. **Bevorzuge das ueber "
|
||||||
|
"memory_save** wenn der User eine Korrektur macht oder du zusaetzliche "
|
||||||
|
"Details zum gleichen Thema hast — vermeidet doppelte Eintraege."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string", "description": "Memory-ID (UUID, aus memory_search oder Cold-Memory)"},
|
||||||
|
"title": {"type": "string", "description": "Neuer Titel (optional)"},
|
||||||
|
"content": {"type": "string", "description": "Neuer Content — wird neu embedded fuer Search (optional)"},
|
||||||
|
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
|
||||||
|
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
|
||||||
|
},
|
||||||
|
"required": ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "memory_save",
|
||||||
|
"description": (
|
||||||
|
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
|
||||||
|
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
|
||||||
|
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
|
||||||
|
"File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
|
||||||
|
"Type-Wahl:\n"
|
||||||
|
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\n"
|
||||||
|
"- rule: harte Regel / Sicherheit / Werte (PINNED)\n"
|
||||||
|
"- preference: Stefans Vorlieben/Arbeitsweise (PINNED)\n"
|
||||||
|
"- tool: Tool-Freigaben / Infrastruktur (PINNED)\n"
|
||||||
|
"- skill: Faehigkeit / Workflow-Anleitung (PINNED)\n"
|
||||||
|
"- fact: Wissen ueber Stefan/Welt/Sachen (Vorlieben, Besitz, Orte, "
|
||||||
|
"Termine, Personen). Cold Memory, kommt nur via Semantic Search "
|
||||||
|
"rein. **Default fuer 'merk-dir-das'-Anfragen.**\n"
|
||||||
|
"- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n"
|
||||||
|
"Wenn unsicher: type=fact, pinned=false.\n\n"
|
||||||
|
"### Anhaenge\n"
|
||||||
|
"`attach_paths` haengt Dateien (Bilder, PDFs, ...) aus `/shared/uploads/` "
|
||||||
|
"an die Memory. Pfade kommen typischerweise aus dem Chat (Stefan haengt "
|
||||||
|
"ein Foto an, du siehst den Pfad in der User-Message).\n\n"
|
||||||
|
"**WICHTIG vor dem Speichern bei Bildern**: Schau dir das Bild ZUERST "
|
||||||
|
"an mit `Read <pfad>` (dein Read-Tool ist multi-modal — es liest Bilder "
|
||||||
|
"wie Vision-API). Extrahiere alles Relevante in den content: sichtbare "
|
||||||
|
"Texte, Marken/Modelle, Kennzeichen/Seriennummern, Personen, Orte, "
|
||||||
|
"auffaellige Details. Dann erst memory_save mit dem extrahierten "
|
||||||
|
"content + attach_paths fuer das Bild. So weisst du beim spaeteren "
|
||||||
|
"Cold-Memory-Lookup was im Bild war, ohne es nochmal lesen zu muessen.\n\n"
|
||||||
|
"Beispiel-Workflow:\n"
|
||||||
|
"1. User: 'Ich hab eine Cessna 172' + /shared/uploads/aria_xy.jpg\n"
|
||||||
|
"2. Du: `Read /shared/uploads/aria_xy.jpg` → siehst Foto, erkennst Kennung D-EAAA\n"
|
||||||
|
"3. Du: `memory_save(type='fact', title='Stefans Cessna 172', "
|
||||||
|
"content='Stefan besitzt eine Cessna 172, Kennung D-EAAA, "
|
||||||
|
"weiss/rot lackiert, vor Hangar fotografiert.', "
|
||||||
|
"attach_paths=['/shared/uploads/aria_xy.jpg'])`"
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"},
|
||||||
|
"content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
|
||||||
|
"description": "Memory-Typ (siehe oben)",
|
||||||
|
},
|
||||||
|
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
|
||||||
|
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
|
||||||
|
"attach_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional. Pfade unter /shared/uploads/ die als Anhang an die Memory wandern. Files werden serverseitig nach /shared/memory-attachments/<id>/ kopiert — Originale bleiben.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["title", "content", "type"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -241,6 +370,14 @@ def _skill_to_tool(s: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class Agent:
|
class Agent:
|
||||||
|
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
|
||||||
|
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
|
||||||
|
# Rauschen — die MiniLM-multilingual Embeddings haben fuer "irgendwas
|
||||||
|
# vs. irgendwas anderes" gerne mal 0.10-0.20 Score selbst bei voellig
|
||||||
|
# unverwandten Inhalten. Mit 0.30 als Untergrenze vermeiden wir
|
||||||
|
# Cross-Talk (z.B. 'hab ich ein flugzeug' triggert die Firmenadresse).
|
||||||
|
COLD_SCORE_THRESHOLD = 0.30
|
||||||
|
|
||||||
def __init__(self, store: VectorStore, embedder: Embedder,
|
def __init__(self, store: VectorStore, embedder: Embedder,
|
||||||
conversation: Conversation, proxy: ProxyClient,
|
conversation: Conversation, proxy: ProxyClient,
|
||||||
cold_k: int = 5):
|
cold_k: int = 5):
|
||||||
@@ -278,10 +415,13 @@ class Agent:
|
|||||||
# 2. Hot Memory (alle pinned Punkte)
|
# 2. Hot Memory (alle pinned Punkte)
|
||||||
hot = self.store.list_pinned()
|
hot = self.store.list_pinned()
|
||||||
|
|
||||||
# 3. Cold Memory (Top-K semantic)
|
# 3. Cold Memory (Top-K semantic) — mit Score-Threshold gegen Rauschen
|
||||||
try:
|
try:
|
||||||
qvec = self.embedder.embed(user_message)
|
qvec = self.embedder.embed(user_message)
|
||||||
cold = self.store.search(qvec, k=self.cold_k, exclude_pinned=True)
|
cold = self.store.search(
|
||||||
|
qvec, k=self.cold_k, exclude_pinned=True,
|
||||||
|
score_threshold=self.COLD_SCORE_THRESHOLD,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
|
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
|
||||||
cold = []
|
cold = []
|
||||||
@@ -467,6 +607,161 @@ class Agent:
|
|||||||
else:
|
else:
|
||||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
if name == "memory_search":
|
||||||
|
query = (arguments.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return "FEHLER: query ist Pflicht."
|
||||||
|
mode = arguments.get("mode") or "text"
|
||||||
|
try:
|
||||||
|
k = int(arguments.get("k", 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
k = 5
|
||||||
|
k = max(1, min(k, 20))
|
||||||
|
try:
|
||||||
|
if mode == "semantic":
|
||||||
|
qvec = self.embedder.embed(query)
|
||||||
|
results = self.store.search(
|
||||||
|
qvec, k=k, exclude_pinned=False, score_threshold=0.30,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results = self.store.search_text(query, k=k, exclude_pinned=False)
|
||||||
|
if not results:
|
||||||
|
return f"Keine Treffer fuer '{query}' (mode={mode})."
|
||||||
|
lines = [f"{len(results)} Treffer fuer '{query}' (mode={mode}):"]
|
||||||
|
for m in results:
|
||||||
|
score_part = f" [score={m.score:.2f}]" if m.score is not None else ""
|
||||||
|
pin = "📌 " if m.pinned else ""
|
||||||
|
atts = m.attachments or []
|
||||||
|
att_part = f" 📎{len(atts)}" if atts else ""
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"## {pin}{m.title} ({m.type}){score_part}{att_part}")
|
||||||
|
lines.append(f"id: {m.id}")
|
||||||
|
lines.append(m.content or "")
|
||||||
|
if atts:
|
||||||
|
for a in atts:
|
||||||
|
lines.append(f" 📎 {a.get('name', '?')} ({a.get('mime', '')}) — {a.get('path', '')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("memory_search fehlgeschlagen")
|
||||||
|
return f"FEHLER: {e}"
|
||||||
|
if name == "memory_update":
|
||||||
|
pid = (arguments.get("id") or "").strip()
|
||||||
|
if not pid:
|
||||||
|
return "FEHLER: id ist Pflicht."
|
||||||
|
existing = self.store.get(pid)
|
||||||
|
if not existing:
|
||||||
|
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
|
||||||
|
try:
|
||||||
|
from memory.vector_store import COLLECTION
|
||||||
|
import datetime as _dt
|
||||||
|
content_changed = False
|
||||||
|
if "title" in arguments and arguments["title"] is not None:
|
||||||
|
existing.title = str(arguments["title"]).strip()
|
||||||
|
if "content" in arguments and arguments["content"] is not None:
|
||||||
|
new_content = str(arguments["content"]).strip()
|
||||||
|
if new_content != existing.content:
|
||||||
|
content_changed = True
|
||||||
|
existing.content = new_content
|
||||||
|
if "category" in arguments and arguments["category"] is not None:
|
||||||
|
existing.category = str(arguments["category"]).strip()
|
||||||
|
if "tags" in arguments and arguments["tags"] is not None:
|
||||||
|
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
|
||||||
|
if "pinned" in arguments and arguments["pinned"] is not None:
|
||||||
|
existing.pinned = bool(arguments["pinned"])
|
||||||
|
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||||
|
if content_changed:
|
||||||
|
vec = self.embedder.embed(existing.content)
|
||||||
|
self.store.upsert(existing, vec)
|
||||||
|
else:
|
||||||
|
self.store.client.set_payload(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
payload=existing.to_payload() | {"updated_at": existing.updated_at},
|
||||||
|
points=[pid],
|
||||||
|
)
|
||||||
|
saved = self.store.get(pid)
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "memory_saved",
|
||||||
|
"action": "updated",
|
||||||
|
"memory": {
|
||||||
|
"id": saved.id, "type": saved.type, "title": saved.title,
|
||||||
|
"content_preview": (saved.content or "")[:140],
|
||||||
|
"category": saved.category, "pinned": saved.pinned,
|
||||||
|
"attachments": saved.attachments or [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return f"OK — Memory '{saved.title}' aktualisiert (id={pid[:8]})."
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("memory_update fehlgeschlagen")
|
||||||
|
return f"FEHLER: {e}"
|
||||||
|
if name == "memory_save":
|
||||||
|
title = (arguments.get("title") or "").strip()
|
||||||
|
content = (arguments.get("content") or "").strip()
|
||||||
|
mem_type = (arguments.get("type") or "fact").strip()
|
||||||
|
if not title or not content:
|
||||||
|
return "FEHLER: title und content sind Pflicht."
|
||||||
|
valid_types = {"identity", "rule", "preference", "tool",
|
||||||
|
"skill", "fact", "conversation", "reminder"}
|
||||||
|
if mem_type not in valid_types:
|
||||||
|
return f"FEHLER: type muss einer von {sorted(valid_types)} sein."
|
||||||
|
category = (arguments.get("category") or "").strip()
|
||||||
|
tags_in = arguments.get("tags") or []
|
||||||
|
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
|
||||||
|
pinned = bool(arguments.get("pinned", False))
|
||||||
|
attach_paths_in = arguments.get("attach_paths") or []
|
||||||
|
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
|
||||||
|
try:
|
||||||
|
from memory import MemoryPoint
|
||||||
|
vec = self.embedder.embed(content)
|
||||||
|
point = MemoryPoint(
|
||||||
|
id="", type=mem_type, title=title, content=content,
|
||||||
|
pinned=pinned, category=category, source="aria", tags=tags,
|
||||||
|
)
|
||||||
|
pid = self.store.upsert(point, vec)
|
||||||
|
# Anhaenge kopieren + Payload updaten
|
||||||
|
attach_errors: list[str] = []
|
||||||
|
if attach_paths:
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
new_atts = []
|
||||||
|
for src in attach_paths:
|
||||||
|
try:
|
||||||
|
meta = mem_att.attach_from_path(pid, src)
|
||||||
|
new_atts.append(meta)
|
||||||
|
except ValueError as e:
|
||||||
|
attach_errors.append(f"{src}: {e}")
|
||||||
|
if new_atts:
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
from memory.vector_store import COLLECTION
|
||||||
|
import datetime as _dt
|
||||||
|
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||||
|
current = self.store.get(pid)
|
||||||
|
current.attachments = (current.attachments or []) + new_atts
|
||||||
|
current.updated_at = now
|
||||||
|
self.store.client.set_payload(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
payload=current.to_payload() | {"updated_at": now},
|
||||||
|
points=[pid],
|
||||||
|
)
|
||||||
|
saved = self.store.get(pid)
|
||||||
|
self._pending_events.append({
|
||||||
|
"type": "memory_saved",
|
||||||
|
"action": "created",
|
||||||
|
"memory": {
|
||||||
|
"id": saved.id, "type": saved.type, "title": saved.title,
|
||||||
|
"content_preview": (saved.content or "")[:140],
|
||||||
|
"category": saved.category, "pinned": saved.pinned,
|
||||||
|
"attachments": saved.attachments or [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
n_att = len(saved.attachments or [])
|
||||||
|
msg = (f"OK — Memory '{title}' gespeichert "
|
||||||
|
f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}"
|
||||||
|
+ (f", {n_att} Anhang/Anhaenge" if n_att else "") + ").")
|
||||||
|
if attach_errors:
|
||||||
|
msg += "\nHinweis: nicht alle Anhaenge konnten kopiert werden:\n - " + "\n - ".join(attach_errors)
|
||||||
|
return msg
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("memory_save fehlgeschlagen")
|
||||||
|
return f"FEHLER beim Speichern: {e}"
|
||||||
return f"Unbekanntes Tool: {name}"
|
return f"Unbekanntes Tool: {name}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
+166
-1
@@ -23,7 +23,7 @@ from typing import List, Optional
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -114,6 +114,10 @@ class MemoryIn(BaseModel):
|
|||||||
source: str = "manual"
|
source: str = "manual"
|
||||||
tags: List[str] = Field(default_factory=list)
|
tags: List[str] = Field(default_factory=list)
|
||||||
conversation_id: Optional[str] = None
|
conversation_id: Optional[str] = None
|
||||||
|
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
|
||||||
|
# nach dem Save via /memory/{id}/attachments hinzugefuegt — hier eher fuer
|
||||||
|
# Bootstrap-Import/Restore-Faelle relevant).
|
||||||
|
attachments: List[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MemoryUpdate(BaseModel):
|
class MemoryUpdate(BaseModel):
|
||||||
@@ -137,12 +141,19 @@ class MemoryOut(BaseModel):
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
conversation_id: Optional[str] = None
|
conversation_id: Optional[str] = None
|
||||||
score: Optional[float] = None
|
score: Optional[float] = None
|
||||||
|
attachments: List[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_point(cls, p: MemoryPoint) -> "MemoryOut":
|
def from_point(cls, p: MemoryPoint) -> "MemoryOut":
|
||||||
return cls(**p.__dict__)
|
return cls(**p.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentUploadBody(BaseModel):
|
||||||
|
"""Base64-Upload via JSON — Diagnostic schickt Files so."""
|
||||||
|
name: str
|
||||||
|
data_base64: str
|
||||||
|
|
||||||
|
|
||||||
# ─── Health ───────────────────────────────────────────────────────────
|
# ─── Health ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -156,6 +167,16 @@ def health():
|
|||||||
|
|
||||||
# ─── Memory-Endpoints ─────────────────────────────────────────────────
|
# ─── Memory-Endpoints ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/memory/get/{point_id}", response_model=MemoryOut)
|
||||||
|
def memory_get(point_id: str):
|
||||||
|
"""Einzelner Memory mit allen Feldern (inkl. Anhaengen).
|
||||||
|
Pfad-Prefix /memory/get/ vermeidet Konflikt mit /memory/list, /memory/save etc."""
|
||||||
|
m = store().get(point_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
|
return MemoryOut.from_point(m)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/memory/stats")
|
@app.get("/memory/stats")
|
||||||
def memory_stats():
|
def memory_stats():
|
||||||
s = store()
|
s = store()
|
||||||
@@ -181,6 +202,23 @@ def memory_pinned():
|
|||||||
return [MemoryOut.from_point(p) for p in store().list_pinned()]
|
return [MemoryOut.from_point(p) for p in store().list_pinned()]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/memory/search-text", response_model=List[MemoryOut])
|
||||||
|
def memory_search_text(
|
||||||
|
q: str,
|
||||||
|
k: int = 50,
|
||||||
|
type: Optional[str] = None,
|
||||||
|
include_pinned: bool = True,
|
||||||
|
):
|
||||||
|
"""Volltext-Substring-Suche (case-insensitive) ueber Title + Content +
|
||||||
|
Category + Tags. Findet exakte Begriffe — z.B. 'auto' matched 'Stefans Auto'.
|
||||||
|
Im Gegensatz zu /memory/search (semantic) keine 'klingt aehnlich'-Treffer."""
|
||||||
|
points = store().search_text(
|
||||||
|
q, k=k, type_filter=type,
|
||||||
|
exclude_pinned=not include_pinned,
|
||||||
|
)
|
||||||
|
return [MemoryOut.from_point(p) for p in points]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/memory/search", response_model=List[MemoryOut])
|
@app.get("/memory/search", response_model=List[MemoryOut])
|
||||||
def memory_search(
|
def memory_search(
|
||||||
q: str,
|
q: str,
|
||||||
@@ -214,6 +252,7 @@ def memory_save(body: MemoryIn):
|
|||||||
source=body.source,
|
source=body.source,
|
||||||
tags=body.tags,
|
tags=body.tags,
|
||||||
conversation_id=body.conversation_id,
|
conversation_id=body.conversation_id,
|
||||||
|
attachments=body.attachments or [],
|
||||||
)
|
)
|
||||||
pid = s.upsert(point, vec)
|
pid = s.upsert(point, vec)
|
||||||
saved = s.get(pid)
|
saved = s.get(pid)
|
||||||
@@ -262,9 +301,125 @@ def memory_delete(point_id: str):
|
|||||||
if not s.get(point_id):
|
if not s.get(point_id):
|
||||||
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
s.delete(point_id)
|
s.delete(point_id)
|
||||||
|
# Anhaenge mit-loeschen damit nichts verwaist
|
||||||
|
try:
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
n = mem_att.delete_all(point_id)
|
||||||
|
if n:
|
||||||
|
logger.info("Memory %s + %d Anhaenge geloescht", point_id, n)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Anhang-Cleanup fuer %s fehlgeschlagen: %s", point_id, exc)
|
||||||
return {"deleted": point_id}
|
return {"deleted": point_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/memory/{point_id}/attachments")
|
||||||
|
def memory_attachments_list(point_id: str):
|
||||||
|
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
|
||||||
|
in der DB, aber wir mergen vorsichtshalber mit dem Filesystem-Stand
|
||||||
|
(falls ein Upload-Restart zwischendrin schiefging)."""
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
s = store()
|
||||||
|
m = s.get(point_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
|
return {"memory_id": point_id, "attachments": mem_att.list_attachments(point_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_attachment_meta(point_id: str, meta: dict) -> MemoryOut:
|
||||||
|
"""Shared-Helper: nach FS-Write das Payload um den neuen Anhang updaten.
|
||||||
|
Duplikat-Name wird ersetzt, sonst hinten dran."""
|
||||||
|
s = store()
|
||||||
|
m = s.get(point_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
|
atts = [a for a in (m.attachments or []) if a.get("name") != meta["name"]]
|
||||||
|
atts.append(meta)
|
||||||
|
m.attachments = atts
|
||||||
|
from memory.vector_store import COLLECTION
|
||||||
|
import datetime as _dt
|
||||||
|
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||||
|
s.client.set_payload(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
payload=m.to_payload() | {"updated_at": m.updated_at},
|
||||||
|
points=[point_id],
|
||||||
|
)
|
||||||
|
return MemoryOut.from_point(s.get(point_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/memory/{point_id}/attachments", response_model=MemoryOut)
|
||||||
|
def memory_attachments_add(point_id: str, body: AttachmentUploadBody):
|
||||||
|
"""Anhang als Base64 hochladen — fuer Diagnostic + interne Tools.
|
||||||
|
Fuer grosse Files lieber multipart-Variante (/upload) nutzen,
|
||||||
|
Base64 sprengt schnell die Bash-ARG_MAX-Grenze beim curl."""
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
if not store().get(point_id):
|
||||||
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
|
try:
|
||||||
|
meta = mem_att.save_from_base64(point_id, body.name, body.data_base64)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
return _commit_attachment_meta(point_id, meta)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/memory/{point_id}/attachments/upload", response_model=MemoryOut)
|
||||||
|
async def memory_attachments_upload(point_id: str, file: UploadFile = File(...)):
|
||||||
|
"""Multipart-Upload — Standard fuer Browser-FormData und curl -F.
|
||||||
|
Verwendung:
|
||||||
|
curl -F file=@foto.jpg "$ARIA_BRAIN_URL/memory/<id>/attachments/upload"
|
||||||
|
"""
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
if not store().get(point_id):
|
||||||
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
|
data = await file.read()
|
||||||
|
try:
|
||||||
|
meta = mem_att.save_attachment(point_id, file.filename or "datei", data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
return _commit_attachment_meta(point_id, meta)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/memory/{point_id}/attachments/{filename}", response_model=MemoryOut)
|
||||||
|
def memory_attachments_delete(point_id: str, filename: str):
|
||||||
|
"""Einzelnen Anhang loeschen (FS + Payload-Eintrag)."""
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
s = store()
|
||||||
|
m = s.get(point_id)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
|
||||||
|
removed_fs = mem_att.delete_attachment(point_id, filename)
|
||||||
|
safe = filename # Cleanup synchron mit FS — Payload-Match per name
|
||||||
|
atts = [a for a in (m.attachments or []) if a.get("name") not in (filename, safe)]
|
||||||
|
m.attachments = atts
|
||||||
|
from qdrant_client.http import models as qm
|
||||||
|
from memory.vector_store import COLLECTION
|
||||||
|
import datetime as _dt
|
||||||
|
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
||||||
|
s.client.set_payload(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
payload=m.to_payload() | {"updated_at": m.updated_at},
|
||||||
|
points=[point_id],
|
||||||
|
)
|
||||||
|
if not removed_fs and not atts:
|
||||||
|
# weder im FS noch im Payload war was — Anhang existierte nicht
|
||||||
|
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
|
||||||
|
return MemoryOut.from_point(s.get(point_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/memory/{point_id}/attachments/{filename}")
|
||||||
|
def memory_attachments_get(point_id: str, filename: str):
|
||||||
|
"""Liefert die Bytes eines Anhangs. Diagnostic-Server kann das
|
||||||
|
durchproxien zur Vorschau/Download in der UI."""
|
||||||
|
import memory_attachments as mem_att
|
||||||
|
import mimetypes as _mt
|
||||||
|
data = mem_att.read_bytes(point_id, filename)
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
|
||||||
|
mime = _mt.guess_type(filename)[0] or "application/octet-stream"
|
||||||
|
return Response(content=data, media_type=mime)
|
||||||
|
|
||||||
|
|
||||||
# ─── Migration aus brain-import/ ──────────────────────────────────────
|
# ─── Migration aus brain-import/ ──────────────────────────────────────
|
||||||
|
|
||||||
IMPORT_DIR = os.environ.get("IMPORT_DIR", "/import")
|
IMPORT_DIR = os.environ.get("IMPORT_DIR", "/import")
|
||||||
@@ -502,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
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class MemoryPoint:
|
|||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
conversation_id: Optional[str] = None
|
conversation_id: Optional[str] = None
|
||||||
score: Optional[float] = None # nur bei Search gesetzt
|
score: Optional[float] = None # nur bei Search gesetzt
|
||||||
|
# Anhaenge: Liste von Dicts {name, mime, size, path} — Dateien liegen
|
||||||
|
# physisch unter /shared/memory-attachments/<memory-id>/<name>.
|
||||||
|
# Hier in der DB nur die Metadaten, damit die Suche/Anzeige sie kennt
|
||||||
|
# ohne Filesystem zu pruefen.
|
||||||
|
attachments: List[dict] = field(default_factory=list)
|
||||||
|
|
||||||
def to_payload(self) -> dict:
|
def to_payload(self) -> dict:
|
||||||
p = {
|
p = {
|
||||||
@@ -72,6 +77,7 @@ class MemoryPoint:
|
|||||||
"tags": self.tags,
|
"tags": self.tags,
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"updated_at": self.updated_at,
|
"updated_at": self.updated_at,
|
||||||
|
"attachments": self.attachments,
|
||||||
}
|
}
|
||||||
if self.conversation_id:
|
if self.conversation_id:
|
||||||
p["conversation_id"] = self.conversation_id
|
p["conversation_id"] = self.conversation_id
|
||||||
@@ -92,6 +98,7 @@ class MemoryPoint:
|
|||||||
created_at=payload.get("created_at", ""),
|
created_at=payload.get("created_at", ""),
|
||||||
updated_at=payload.get("updated_at", ""),
|
updated_at=payload.get("updated_at", ""),
|
||||||
conversation_id=payload.get("conversation_id"),
|
conversation_id=payload.get("conversation_id"),
|
||||||
|
attachments=payload.get("attachments", []) or [],
|
||||||
score=getattr(point, "score", None),
|
score=getattr(point, "score", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,3 +220,56 @@ class VectorStore:
|
|||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
return self.client.count(collection_name=COLLECTION, exact=True).count
|
return self.client.count(collection_name=COLLECTION, exact=True).count
|
||||||
|
|
||||||
|
def search_text(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
k: int = 20,
|
||||||
|
type_filter: Optional[str] = None,
|
||||||
|
exclude_pinned: bool = False,
|
||||||
|
) -> List[MemoryPoint]:
|
||||||
|
"""Volltext-Substring-Suche (case-insensitive) ueber Title +
|
||||||
|
Content + Category + Tags. Im Gegensatz zu search() ist das KEIN
|
||||||
|
Semantic-Match — nur exakte Wort-/Teilwort-Treffer.
|
||||||
|
|
||||||
|
Full-Scan ueber alle (gefilteren) Punkte. Bei der erwarteten
|
||||||
|
Groessenordnung (< 1000) unkritisch."""
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
must = []
|
||||||
|
must_not = []
|
||||||
|
if type_filter:
|
||||||
|
must.append(qm.FieldCondition(key="type", match=qm.MatchValue(value=type_filter)))
|
||||||
|
if exclude_pinned:
|
||||||
|
must_not.append(qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)))
|
||||||
|
flt = qm.Filter(must=must or None, must_not=must_not or None) if (must or must_not) else None
|
||||||
|
|
||||||
|
matches: List[MemoryPoint] = []
|
||||||
|
offset = None
|
||||||
|
while True:
|
||||||
|
points, offset = self.client.scroll(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
scroll_filter=flt,
|
||||||
|
limit=200,
|
||||||
|
offset=offset,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
for p in points:
|
||||||
|
payload = p.payload or {}
|
||||||
|
tags = payload.get("tags")
|
||||||
|
tags_str = " ".join(tags) if isinstance(tags, list) else ""
|
||||||
|
haystack = " ".join([
|
||||||
|
str(payload.get("title", "")),
|
||||||
|
str(payload.get("content", "")),
|
||||||
|
str(payload.get("category", "")),
|
||||||
|
tags_str,
|
||||||
|
]).lower()
|
||||||
|
if q in haystack:
|
||||||
|
matches.append(MemoryPoint.from_qdrant(p))
|
||||||
|
if len(matches) >= k:
|
||||||
|
return matches
|
||||||
|
if not offset:
|
||||||
|
break
|
||||||
|
return matches
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
Anhaenge fuer Memory-Eintraege.
|
||||||
|
|
||||||
|
Storage-Layout:
|
||||||
|
/shared/memory-attachments/<memory-id>/<original-name>
|
||||||
|
|
||||||
|
Eine flache Ordnerstruktur pro Memory — bei Memory-Delete loescht main.py
|
||||||
|
das ganze Verzeichnis. Anhang-Metadaten (name, mime, size, path) liegen
|
||||||
|
zusaetzlich im Qdrant-Payload des Memory-Punkts damit die Listen/Suche
|
||||||
|
sie ohne Filesystem-Lookup zeigen kann.
|
||||||
|
|
||||||
|
Anhaenge sind erstmal nur ueber die Diagnostic-UI hochladbar — ARIA
|
||||||
|
selbst hat in Stufe A kein Tool zum Upload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
|
||||||
|
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
|
||||||
|
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str) -> str:
|
||||||
|
"""Macht aus einem User-Namen einen filesystem-sicheren String —
|
||||||
|
zerlegt Pfadteile, schneidet Sonderzeichen weg, kuerzt auf 120 Zeichen."""
|
||||||
|
base = Path(name).name or "datei"
|
||||||
|
base = SAFE_NAME_RE.sub("_", base).strip("._-") or "datei"
|
||||||
|
return base[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def memory_dir(memory_id: str) -> Path:
|
||||||
|
return ROOT / memory_id
|
||||||
|
|
||||||
|
|
||||||
|
def list_attachments(memory_id: str) -> List[dict]:
|
||||||
|
"""Liest die Anhaenge fuer eine Memory aus dem Filesystem.
|
||||||
|
Returns [{name, mime, size, path}, ...] — leer wenn nichts da.
|
||||||
|
Source of Truth ist Qdrant-Payload; diese Funktion ist nur fuer
|
||||||
|
Diagnostic-Endpoints wenn Stefan direkt das FS prueft."""
|
||||||
|
d = memory_dir(memory_id)
|
||||||
|
if not d.is_dir():
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for f in sorted(d.iterdir()):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
out.append(_file_meta(memory_id, f))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _file_meta(memory_id: str, f: Path) -> dict:
|
||||||
|
try:
|
||||||
|
size = f.stat().st_size
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
mime = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
||||||
|
return {
|
||||||
|
"name": f.name,
|
||||||
|
"mime": mime,
|
||||||
|
"size": size,
|
||||||
|
"path": str(f), # absoluter Pfad im Container
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
|
||||||
|
"""Schreibt einen Anhang ins FS und gibt seine Metadaten zurueck.
|
||||||
|
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
|
||||||
|
if not memory_id:
|
||||||
|
raise ValueError("memory_id ist Pflicht")
|
||||||
|
if len(data) > MAX_BYTES:
|
||||||
|
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
|
||||||
|
safe = _safe_filename(filename)
|
||||||
|
d = memory_dir(memory_id)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = d / safe
|
||||||
|
target.write_bytes(data)
|
||||||
|
logger.info("[mem-att] %s -> %s (%d Byte)", memory_id, safe, len(data))
|
||||||
|
return _file_meta(memory_id, target)
|
||||||
|
|
||||||
|
|
||||||
|
def save_from_base64(memory_id: str, filename: str, b64: str) -> dict:
|
||||||
|
"""Convenience fuer Base64-Uploads (Diagnostic schickt Files so)."""
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(b64, validate=False)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Base64-Decode fehlgeschlagen: {exc}") from exc
|
||||||
|
return save_attachment(memory_id, filename, data)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_attachment(memory_id: str, filename: str) -> bool:
|
||||||
|
"""Loescht eine einzelne Anhang-Datei. Returns True wenn was weg ist."""
|
||||||
|
safe = _safe_filename(filename)
|
||||||
|
target = memory_dir(memory_id) / safe
|
||||||
|
if not target.is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
target.unlink()
|
||||||
|
logger.info("[mem-att] %s/%s geloescht", memory_id, safe)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[mem-att] Loeschen fehlgeschlagen: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all(memory_id: str) -> int:
|
||||||
|
"""Loescht das komplette Memory-Verzeichnis. Wird beim Memory-Delete
|
||||||
|
in main.py gerufen damit nichts verwaist."""
|
||||||
|
d = memory_dir(memory_id)
|
||||||
|
if not d.is_dir():
|
||||||
|
return 0
|
||||||
|
count = sum(1 for _ in d.iterdir() if _.is_file())
|
||||||
|
try:
|
||||||
|
shutil.rmtree(d)
|
||||||
|
logger.info("[mem-att] %s komplett entfernt (%d Files)", memory_id, count)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[mem-att] rmtree fehlgeschlagen: %s", exc)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def read_bytes(memory_id: str, filename: str) -> Optional[bytes]:
|
||||||
|
"""Liefert die rohen Bytes einer Datei zurueck — fuer Download/Serve."""
|
||||||
|
safe = _safe_filename(filename)
|
||||||
|
target = memory_dir(memory_id) / safe
|
||||||
|
if not target.is_file():
|
||||||
|
return None
|
||||||
|
return target.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
# /shared/ ist der einzig akzeptable Source-Pfad fuer attach_from_path —
|
||||||
|
# ARIA bekommt Files vom User immer in /shared/uploads, eigene Files
|
||||||
|
# generiert sie in /shared/uploads/ als File-Marker. Kein Zugriff auf
|
||||||
|
# /root, /etc, /tmp, ssh-Keys, etc.
|
||||||
|
ALLOWED_SOURCE_PREFIXES = ("/shared/uploads/", "/shared/memory-attachments/")
|
||||||
|
|
||||||
|
|
||||||
|
def attach_from_path(memory_id: str, source_path: str) -> dict:
|
||||||
|
"""Kopiert eine existierende Datei aus /shared/* in das Anhang-Verzeichnis
|
||||||
|
des Memories und gibt die neue Metadaten zurueck.
|
||||||
|
|
||||||
|
Verwendung: ARIA bekommt z.B. ein User-Bild als `/shared/uploads/aria_<id>.jpg`.
|
||||||
|
Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert
|
||||||
|
sie es via `memory_save(..., attach_paths=[<src>])` ins Memory-Verzeichnis.
|
||||||
|
|
||||||
|
Pfadschutz: source_path MUSS unter /shared/ liegen — kein Zugriff auf
|
||||||
|
Root-FS, SSH-Keys etc.
|
||||||
|
"""
|
||||||
|
if not memory_id:
|
||||||
|
raise ValueError("memory_id ist Pflicht")
|
||||||
|
if not source_path or not isinstance(source_path, str):
|
||||||
|
raise ValueError("source_path leer")
|
||||||
|
if not any(source_path.startswith(p) for p in ALLOWED_SOURCE_PREFIXES):
|
||||||
|
raise ValueError(f"source_path muss unter {' oder '.join(ALLOWED_SOURCE_PREFIXES)} liegen")
|
||||||
|
src = Path(source_path)
|
||||||
|
if not src.is_file():
|
||||||
|
raise ValueError(f"Datei nicht gefunden: {source_path}")
|
||||||
|
size = src.stat().st_size
|
||||||
|
if size > MAX_BYTES:
|
||||||
|
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)")
|
||||||
|
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
|
||||||
|
data = src.read_bytes()
|
||||||
|
return save_attachment(memory_id, src.name, data)
|
||||||
@@ -52,6 +52,44 @@ TYPE_HEADINGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _attachments_line(p: MemoryPoint) -> str:
|
||||||
|
"""Eine Zeile die ARIA verraet welche Dateien an einer Memory haengen.
|
||||||
|
Bilder/Files liegen physisch unter /shared/memory-attachments/<id>/<name>.
|
||||||
|
|
||||||
|
Multi-Modal-Hinweis: Claude Code's `Read`-Tool kann Bilder direkt
|
||||||
|
anschauen (PNG/JPG/GIF/WebP) — sie laufen dann durch das gleiche
|
||||||
|
Vision-Modell wie via Anthropic-Vision-API. Heisst: ARIA muss nur
|
||||||
|
`Read /shared/memory-attachments/<id>/foto.jpg` aufrufen und sieht
|
||||||
|
das Bild wirklich, ohne dass wir Multi-Modal-Messages durch den
|
||||||
|
Proxy schleusen muessen. Wir geben ihr den Hinweis in der Zeile mit.
|
||||||
|
"""
|
||||||
|
atts = getattr(p, "attachments", None) or []
|
||||||
|
if not atts:
|
||||||
|
return ""
|
||||||
|
base_dir = f"/shared/memory-attachments/{p.id}/" if p.id else ""
|
||||||
|
items = []
|
||||||
|
has_image = False
|
||||||
|
for a in atts:
|
||||||
|
if not isinstance(a, dict):
|
||||||
|
continue
|
||||||
|
name = a.get("name", "?")
|
||||||
|
mime = a.get("mime", "")
|
||||||
|
if mime.startswith("image/"):
|
||||||
|
has_image = True
|
||||||
|
size = a.get("size")
|
||||||
|
size_part = f", {size // 1024} KB" if isinstance(size, int) and size else ""
|
||||||
|
items.append(f"{name} ({mime}{size_part})")
|
||||||
|
if not items:
|
||||||
|
return ""
|
||||||
|
line = f"📎 Anhaenge: {', '.join(items)}"
|
||||||
|
if base_dir:
|
||||||
|
line += f" — Pfad: {base_dir}"
|
||||||
|
if has_image and base_dir:
|
||||||
|
line += (" — Bilder kannst du via `Read <pfad>` direkt ansehen "
|
||||||
|
"(Claude Code Read ist multi-modal-faehig)")
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
||||||
"""Baue den 'IMMER-im-Prompt'-Block aus pinned Punkten."""
|
"""Baue den 'IMMER-im-Prompt'-Block aus pinned Punkten."""
|
||||||
grouped: dict[str, List[MemoryPoint]] = {}
|
grouped: dict[str, List[MemoryPoint]] = {}
|
||||||
@@ -69,6 +107,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
|||||||
for p in items:
|
for p in items:
|
||||||
parts.append(f"### {p.title}")
|
parts.append(f"### {p.title}")
|
||||||
parts.append(p.content.strip())
|
parts.append(p.content.strip())
|
||||||
|
att_line = _attachments_line(p)
|
||||||
|
if att_line:
|
||||||
|
parts.append(att_line)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
# uebrige Types (falls jemand was anderes als pinned markiert)
|
# uebrige Types (falls jemand was anderes als pinned markiert)
|
||||||
@@ -77,6 +118,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
|
|||||||
for p in items:
|
for p in items:
|
||||||
parts.append(f"### {p.title}")
|
parts.append(f"### {p.title}")
|
||||||
parts.append(p.content.strip())
|
parts.append(p.content.strip())
|
||||||
|
att_line = _attachments_line(p)
|
||||||
|
if att_line:
|
||||||
|
parts.append(att_line)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
return "\n".join(parts).strip()
|
return "\n".join(parts).strip()
|
||||||
@@ -91,6 +135,9 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
|
|||||||
score = f" [score={p.score:.2f}]" if p.score is not None else ""
|
score = f" [score={p.score:.2f}]" if p.score is not None else ""
|
||||||
lines.append(f"- **{p.title}**{score}")
|
lines.append(f"- **{p.title}**{score}")
|
||||||
lines.append(f" {p.content.strip()}")
|
lines.append(f" {p.content.strip()}")
|
||||||
|
att_line = _attachments_line(p)
|
||||||
|
if att_line:
|
||||||
|
lines.append(f" {att_line}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+137
-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."""
|
||||||
@@ -1376,6 +1403,17 @@ class ARIABridge:
|
|||||||
})
|
})
|
||||||
logger.info("[brain] location_tracking Request: on=%s (%s)",
|
logger.info("[brain] location_tracking Request: on=%s (%s)",
|
||||||
event.get("on"), event.get("reason", ""))
|
event.get("on"), event.get("reason", ""))
|
||||||
|
elif etype == "memory_saved":
|
||||||
|
# ARIA hat selber etwas in die Vector-DB gespeichert.
|
||||||
|
# Eigene Bubble in App + Diagnostic (gelb wie skill/trigger).
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "memory_saved",
|
||||||
|
"payload": event.get("memory", {}),
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)",
|
||||||
|
event.get("memory", {}).get("title"),
|
||||||
|
event.get("memory", {}).get("type"))
|
||||||
|
|
||||||
# _process_core_response uebernimmt alles weitere:
|
# _process_core_response uebernimmt alles weitere:
|
||||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||||
@@ -1813,6 +1851,95 @@ 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":
|
||||||
|
# Generischer RVS-Proxy fuer die Brain-HTTP-API.
|
||||||
|
# payload: {requestId, method, path, body?, bodyBase64?, contentType?}
|
||||||
|
# - method: GET | POST | PATCH | DELETE
|
||||||
|
# - path: z.B. "/memory/list" oder "/memory/get/<id>"
|
||||||
|
# - body: JSON-Objekt (wird als JSON encoded)
|
||||||
|
# - bodyBase64: rohe Bytes als Base64 (fuer Upload mit contentType)
|
||||||
|
# - contentType: default application/json
|
||||||
|
# Antwort als brain_response {requestId, status, json?, base64?}.
|
||||||
|
req_id = payload.get("requestId") or ""
|
||||||
|
method = (payload.get("method") or "GET").upper()
|
||||||
|
path = payload.get("path") or ""
|
||||||
|
if not req_id or not path or not path.startswith("/"):
|
||||||
|
logger.warning("[rvs] brain_request ungueltig: %r", payload)
|
||||||
|
return
|
||||||
|
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||||
|
url = brain_url.rstrip("/") + path
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
data: Optional[bytes] = None
|
||||||
|
ct = payload.get("contentType") or "application/json"
|
||||||
|
if payload.get("bodyBase64"):
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(payload["bodyBase64"])
|
||||||
|
except Exception:
|
||||||
|
data = None
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = ct
|
||||||
|
elif payload.get("body") is not None:
|
||||||
|
data = json.dumps(payload["body"]).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
logger.info("[rvs] brain_request %s %s (%d Byte)", method, path, len(data or b""))
|
||||||
|
|
||||||
|
def _do_call():
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
return r.status, r.read(), r.headers.get("Content-Type", "")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
body = e.read()
|
||||||
|
except Exception:
|
||||||
|
body = b""
|
||||||
|
return e.code, body, e.headers.get("Content-Type", "") if e.headers else ""
|
||||||
|
except Exception as exc:
|
||||||
|
return None, str(exc).encode("utf-8"), "text/plain"
|
||||||
|
|
||||||
|
status, body_bytes, response_ct = await asyncio.get_event_loop().run_in_executor(None, _do_call)
|
||||||
|
out: dict = {"requestId": req_id, "status": status or 0}
|
||||||
|
if response_ct and "json" in response_ct:
|
||||||
|
try:
|
||||||
|
out["json"] = json.loads(body_bytes.decode("utf-8", errors="ignore"))
|
||||||
|
except Exception:
|
||||||
|
out["text"] = body_bytes.decode("utf-8", errors="ignore")[:2000]
|
||||||
|
elif response_ct and "text" in response_ct:
|
||||||
|
out["text"] = body_bytes.decode("utf-8", errors="ignore")[:4000]
|
||||||
|
else:
|
||||||
|
# Binaer (z.B. attachment-download) → base64 zurueck
|
||||||
|
out["base64"] = base64.b64encode(body_bytes).decode("ascii")
|
||||||
|
out["contentType"] = response_ct or "application/octet-stream"
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "brain_response",
|
||||||
|
"payload": out,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
elif msg_type == "file_list_request":
|
elif msg_type == "file_list_request":
|
||||||
# App fragt die Liste aller /shared/uploads/-Dateien an.
|
# App fragt die Liste aller /shared/uploads/-Dateien an.
|
||||||
logger.info("[rvs] file_list_request von App")
|
logger.info("[rvs] file_list_request von App")
|
||||||
@@ -2635,6 +2762,15 @@ class ARIABridge:
|
|||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
})
|
})
|
||||||
|
elif etype == "memory_saved":
|
||||||
|
mem = event.get("memory", {})
|
||||||
|
if event.get("action"):
|
||||||
|
mem = {**mem, "action": event.get("action")}
|
||||||
|
await self._send_to_rvs({
|
||||||
|
"type": "memory_saved",
|
||||||
|
"payload": mem,
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype)
|
logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype)
|
||||||
|
|
||||||
|
|||||||
+412
-18
@@ -824,11 +824,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:8px;">
|
<div class="card" style="margin-bottom:8px;">
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
<input type="text" id="brain-search" placeholder="Semantische Suche (z.B. 'Stefan Persönlichkeit')..."
|
<input type="text" id="brain-search" placeholder="Suche (z.B. 'cessna' oder 'Stefan Persönlichkeit')..."
|
||||||
style="flex:1;min-width:200px;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;"
|
style="flex:1;min-width:200px;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;"
|
||||||
onkeydown="if(event.key==='Enter') runBrainSearch()">
|
onkeydown="if(event.key==='Enter') runBrainSearch()">
|
||||||
|
<select id="brain-search-mode" onchange="if(document.getElementById('brain-search').value.trim()) runBrainSearch()"
|
||||||
|
title="Wortlich = exakter Substring-Match. Semantisch = 'klingt aehnlich' via Embeddings."
|
||||||
|
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||||
|
<option value="text" selected>📝 Wortlich</option>
|
||||||
|
<option value="semantic">🧠 Semantisch</option>
|
||||||
|
</select>
|
||||||
<button class="btn secondary" onclick="runBrainSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
|
<button class="btn secondary" onclick="runBrainSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
|
||||||
<select id="brain-filter-type" onchange="loadBrainMemoryList()"
|
<select id="brain-filter-type" onchange="onBrainFiltersChanged()"
|
||||||
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||||
<option value="">Alle Typen</option>
|
<option value="">Alle Typen</option>
|
||||||
<option value="identity">Identität</option>
|
<option value="identity">Identität</option>
|
||||||
@@ -840,14 +846,29 @@
|
|||||||
<option value="conversation">Konversation</option>
|
<option value="conversation">Konversation</option>
|
||||||
<option value="reminder">Reminder</option>
|
<option value="reminder">Reminder</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="brain-filter-pinned" onchange="loadBrainMemoryList()"
|
<select id="brain-filter-pinned" onchange="onBrainFiltersChanged()"
|
||||||
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
|
||||||
<option value="all">Pinned + Cold</option>
|
<option value="all">Pinned + Cold</option>
|
||||||
<option value="pinned">📌 Nur Pinned</option>
|
<option value="pinned">📌 Nur Pinned</option>
|
||||||
<option value="cold">Nur Cold</option>
|
<option value="cold">Nur Cold</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button>
|
||||||
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button>
|
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
|
||||||
|
<div style="color:#8888AA;font-size:11px;margin-bottom:6px;">
|
||||||
|
Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet.
|
||||||
|
</div>
|
||||||
|
<div id="adv-rows-container" style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<!-- Reihen werden dynamisch via JS gerendert (renderAdvancedRows) -->
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<button class="btn" onclick="runAdvancedSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
|
||||||
|
<button class="btn secondary" onclick="addAdvancedRow()" style="padding:4px 10px;font-size:11px;" title="Weiteres Suchfeld hinzufuegen">+ Feld</button>
|
||||||
|
<button class="btn secondary" onclick="clearAdvancedSearch()" style="padding:4px 10px;font-size:11px;color:#8888AA;">Alle leeren</button>
|
||||||
|
<span style="color:#555570;font-size:10px;margin-left:auto;">Leere Felder werden ignoriert · Min. 1 Feld · ✕ entfernt ein Feld</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="brain-search-info" style="margin-top:6px;font-size:10px;color:#8888AA;display:none;"></div>
|
<div id="brain-search-info" style="margin-top:6px;font-size:10px;color:#8888AA;display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1022,6 +1043,26 @@
|
|||||||
<input type="checkbox" id="memory-pinned">
|
<input type="checkbox" id="memory-pinned">
|
||||||
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
|
||||||
|
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
|
||||||
|
<label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;">
|
||||||
|
<span>📎 Anhaenge</span>
|
||||||
|
<span style="color:#555570;font-size:10px;">max 20 MB pro Datei</span>
|
||||||
|
</label>
|
||||||
|
<div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<label class="btn secondary" style="padding:4px 10px;font-size:11px;cursor:pointer;margin:0;">
|
||||||
|
⬆ Datei waehlen
|
||||||
|
<input type="file" id="memory-attachment-input" multiple style="display:none;" onchange="uploadMemoryAttachments(this.files)">
|
||||||
|
</label>
|
||||||
|
<span id="memory-attachment-status" style="font-size:11px;color:#555570;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="memory-attachments-hint" style="display:none;margin-top:10px;padding:6px 8px;background:#0D0D1A;border-radius:4px;color:#555570;font-size:11px;">
|
||||||
|
📎 Anhaenge kannst du nach dem Speichern hinzufuegen (brauchen eine Memory-ID).
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="memory-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:10px;display:none;"></div>
|
<div id="memory-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:10px;display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
|
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
|
||||||
@@ -1362,6 +1403,14 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'memory_saved') {
|
||||||
|
addMemorySavedBubble(msg.payload || {});
|
||||||
|
// Falls Gehirn-Tab offen: refreshen
|
||||||
|
if (document.getElementById('tab-brain') && document.getElementById('tab-brain').classList.contains('visible')) {
|
||||||
|
loadBrainMemoryList();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === 'chat_delta') { return; }
|
if (msg.type === 'chat_delta') { return; }
|
||||||
if (msg.type === 'chat_error') {
|
if (msg.type === 'chat_error') {
|
||||||
addChat('error', msg.error, 'chat:error');
|
addChat('error', msg.error, 'chat:error');
|
||||||
@@ -1955,6 +2004,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ARIA hat eine Memory in die Qdrant-DB gespeichert — als Bubble anzeigen. */
|
||||||
|
function addMemorySavedBubble(memory) {
|
||||||
|
const title = memory.title || '(ohne Titel)';
|
||||||
|
const type = memory.type || 'fact';
|
||||||
|
const cat = memory.category || '';
|
||||||
|
const pinned = !!memory.pinned;
|
||||||
|
const preview = memory.content_preview || '';
|
||||||
|
const typeLabel = (typeof BRAIN_TYPE_LABELS !== 'undefined' && BRAIN_TYPE_LABELS[type]) || type;
|
||||||
|
const pinBadge = pinned ? '<span style="color:#FFD60A;font-size:11px;margin-left:6px;">📌 pinned</span>' : '';
|
||||||
|
const catBadge = cat ? ` <span style="color:#555570;font-size:10px;">[${escapeHtml(cat)}]</span>` : '';
|
||||||
|
const html = `
|
||||||
|
<div style="font-weight:bold;color:#FFD60A;">🧠 ARIA hat etwas gemerkt</div>
|
||||||
|
<div style="margin-top:4px;color:#E0E0F0;">
|
||||||
|
<strong>${escapeHtml(title)}</strong>
|
||||||
|
<span style="color:#8888AA;font-size:11px;margin-left:6px;">(${escapeHtml(typeLabel)})</span>
|
||||||
|
${pinBadge}${catBadge}
|
||||||
|
</div>
|
||||||
|
${preview ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(preview)}${preview.length >= 140 ? '…' : ''}</div>` : ''}
|
||||||
|
<div class="meta">
|
||||||
|
ARIA-Memory — ${new Date().toLocaleTimeString('de-DE')} ·
|
||||||
|
<a href="#" onclick="event.preventDefault();switchMainTab('brain');" style="color:#FFD60A;">im Gehirn-Tab ansehen</a>
|
||||||
|
</div>`;
|
||||||
|
for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
|
||||||
|
if (!box) continue;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'chat-msg received';
|
||||||
|
el.style.borderLeft = '3px solid #FFD60A';
|
||||||
|
el.innerHTML = html;
|
||||||
|
box.appendChild(el);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Wenn der Server file_deleted broadcastet: alle Bubbles mit
|
/** Wenn der Server file_deleted broadcastet: alle Bubbles mit
|
||||||
diesem serverPath rerendern als "geloescht" markieren. */
|
diesem serverPath rerendern als "geloescht" markieren. */
|
||||||
function markFileDeletedInChat(serverPath) {
|
function markFileDeletedInChat(serverPath) {
|
||||||
@@ -3445,6 +3527,192 @@
|
|||||||
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
|
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
|
||||||
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
|
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
|
||||||
brainSearchIds = null;
|
brainSearchIds = null;
|
||||||
|
clearAdvancedSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAdvancedSearch() {
|
||||||
|
const panel = document.getElementById('brain-advanced-panel');
|
||||||
|
const btn = document.getElementById('btn-advanced-search');
|
||||||
|
if (!panel) return;
|
||||||
|
const open = panel.style.display !== 'none';
|
||||||
|
panel.style.display = open ? 'none' : 'block';
|
||||||
|
if (btn) btn.textContent = open ? '⌃ Erweitert' : '⌄ Einklappen';
|
||||||
|
if (!open) ensureAdvancedRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamische Such-Reihen-Struktur:
|
||||||
|
// advRows = [{term, op}, ...] — die erste Reihe hat op=null,
|
||||||
|
// jede weitere bekommt einen UND/ODER-Selektor links und einen ✕ rechts.
|
||||||
|
let advRows = [{ term: '', op: null }];
|
||||||
|
|
||||||
|
function ensureAdvancedRows() {
|
||||||
|
if (!advRows.length) advRows = [{ term: '', op: null }];
|
||||||
|
renderAdvancedRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAdvancedRow() {
|
||||||
|
// Vor dem Re-render aktuelle Werte aus DOM uebernehmen damit nichts verloren geht
|
||||||
|
syncAdvancedRowsFromDOM();
|
||||||
|
advRows.push({ term: '', op: 'AND' });
|
||||||
|
renderAdvancedRows();
|
||||||
|
// Fokus auf das neue Feld
|
||||||
|
const last = document.querySelector(`#adv-rows-container .adv-row:last-child input.adv-term`);
|
||||||
|
if (last) last.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAdvancedRow(idx) {
|
||||||
|
syncAdvancedRowsFromDOM();
|
||||||
|
if (advRows.length <= 1) return; // erste bleibt
|
||||||
|
advRows.splice(idx, 1);
|
||||||
|
// Erste Reihe hat immer op=null
|
||||||
|
if (advRows[0]) advRows[0].op = null;
|
||||||
|
renderAdvancedRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAdvancedRowsFromDOM() {
|
||||||
|
const rows = document.querySelectorAll('#adv-rows-container .adv-row');
|
||||||
|
const next = [];
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
const term = (row.querySelector('input.adv-term')?.value || '');
|
||||||
|
const op = i === 0 ? null : (row.querySelector('select.adv-op')?.value || 'AND');
|
||||||
|
next.push({ term, op });
|
||||||
|
});
|
||||||
|
if (next.length) advRows = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdvancedRows() {
|
||||||
|
const container = document.getElementById('adv-rows-container');
|
||||||
|
if (!container) return;
|
||||||
|
const inputStyle = 'flex:1;min-width:0;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:12px;';
|
||||||
|
const selectStyle = 'background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;width:70px;';
|
||||||
|
container.innerHTML = advRows.map((r, i) => {
|
||||||
|
const ph = i === 0 ? 'z.B. flugzeug' : 'z.B. cessna';
|
||||||
|
const term = (r.term || '').replace(/"/g, '"');
|
||||||
|
if (i === 0) {
|
||||||
|
return `<div class="adv-row" style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<span style="width:70px;color:#555570;font-size:11px;text-align:center;">Start</span>
|
||||||
|
<input type="text" class="adv-term" placeholder="${ph}" value="${term}" style="${inputStyle}">
|
||||||
|
<span style="width:24px;"></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const op = r.op || 'AND';
|
||||||
|
return `<div class="adv-row" style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<select class="adv-op" style="${selectStyle}">
|
||||||
|
<option value="AND"${op === 'AND' ? ' selected' : ''}>UND</option>
|
||||||
|
<option value="OR"${op === 'OR' ? ' selected' : ''}>ODER</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="adv-term" placeholder="${ph}" value="${term}" style="${inputStyle}">
|
||||||
|
<button class="btn secondary" onclick="removeAdvancedRow(${i})" title="Diese Zeile entfernen" style="width:24px;height:24px;padding:0;line-height:20px;font-size:11px;color:#FF6B6B;">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdvancedSearch() {
|
||||||
|
advRows = [{ term: '', op: null }];
|
||||||
|
renderAdvancedRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mehrere Volltext-Suchen + Boolean-Kombination (links nach rechts).
|
||||||
|
* Backend bleibt simpel — wir machen N parallele search-text-Calls
|
||||||
|
* und kombinieren die ID-Mengen client-seitig per AND/OR. */
|
||||||
|
async function runAdvancedSearch() {
|
||||||
|
syncAdvancedRowsFromDOM();
|
||||||
|
const info = document.getElementById('brain-search-info');
|
||||||
|
// Nur Reihen mit Inhalt einsammeln. Die erste belegte Reihe wird zum
|
||||||
|
// Start-Term (op=null), egal an welchem Index sie ursprünglich war.
|
||||||
|
const active = [];
|
||||||
|
for (const r of advRows) {
|
||||||
|
const t = (r.term || '').trim();
|
||||||
|
if (!t) continue;
|
||||||
|
active.push({ term: t, op: active.length === 0 ? null : (r.op || 'AND') });
|
||||||
|
}
|
||||||
|
if (active.length === 0) {
|
||||||
|
if (info) info.style.display = 'none';
|
||||||
|
loadBrainMemoryList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeFilter = document.getElementById('brain-filter-type').value;
|
||||||
|
const baseParams = { k: '500', include_pinned: 'true' };
|
||||||
|
if (typeFilter) baseParams.type = typeFilter;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pro Begriff einmal Backend fragen, dann Map<id, memory> + Set<id>
|
||||||
|
const sets = [];
|
||||||
|
for (const a of active) {
|
||||||
|
const params = new URLSearchParams({ ...baseParams, q: a.term });
|
||||||
|
const r = await fetch('/api/brain/memory/search-text?' + params.toString());
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const hits = await r.json();
|
||||||
|
hits.forEach(m => { brainMemoryCache[m.id] = m; });
|
||||||
|
sets.push(new Set(hits.map(m => m.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links-nach-rechts kombinieren mit den Operatoren
|
||||||
|
let combined = sets[0];
|
||||||
|
for (let i = 1; i < sets.length; i++) {
|
||||||
|
const op = active[i].op;
|
||||||
|
if (op === 'AND') {
|
||||||
|
combined = new Set([...combined].filter(id => sets[i].has(id)));
|
||||||
|
} else {
|
||||||
|
combined = new Set([...combined, ...sets[i]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hits = Array.from(combined).map(id => brainMemoryCache[id]).filter(Boolean);
|
||||||
|
const totalHits = hits.length;
|
||||||
|
hits = applyPinnedFilter(hits);
|
||||||
|
brainSearchIds = hits.map(m => m.id);
|
||||||
|
const desc = active.map((a, i) => i === 0 ? `"${a.term}"` : ` ${a.op} "${a.term}"`).join('');
|
||||||
|
const pinnedFilter = document.getElementById('brain-filter-pinned')?.value || 'all';
|
||||||
|
const pinnedLabel = pinnedFilter === 'pinned' ? ' · 📌 nur pinned'
|
||||||
|
: pinnedFilter === 'cold' ? ' · nur cold'
|
||||||
|
: '';
|
||||||
|
if (info) {
|
||||||
|
info.style.display = 'block';
|
||||||
|
const filterDesc = (typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') + pinnedLabel;
|
||||||
|
if (hits.length === 0) {
|
||||||
|
const extra = totalHits > 0 ? ` (${totalHits} Treffer ohne Pinned-Filter)` : '';
|
||||||
|
info.innerHTML = `🔍 Keine Treffer fuer ${escapeHtml(desc)}${filterDesc}${extra} · 📝 wortlich, Boolean-Kombi`;
|
||||||
|
} else {
|
||||||
|
info.innerHTML = `🔍 ${hits.length} Treffer fuer ${escapeHtml(desc)}${filterDesc} · 📝 wortlich, Boolean-Kombi`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderBrainList(hits, true);
|
||||||
|
} catch (e) {
|
||||||
|
if (info) {
|
||||||
|
info.style.display = 'block';
|
||||||
|
info.innerHTML = `🔴 Erweiterte Suche fehlgeschlagen: ${escapeHtml(e.message)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True wenn aktuell eine Search-Ansicht aktiv ist (Single oder Advanced).
|
||||||
|
* Wird vom Pinned/Type-Filter-onchange genutzt um statt loadBrainMemoryList
|
||||||
|
* die Suche neu auszufuehren — damit Filter auch bei aktiver Suche greifen. */
|
||||||
|
function brainSearchActive() {
|
||||||
|
const q = (document.getElementById('brain-search')?.value || '').trim();
|
||||||
|
if (q) return 'single';
|
||||||
|
const hasAdv = (advRows || []).some(r => (r.term || '').trim());
|
||||||
|
return hasAdv ? 'advanced' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wird vom Type+Pinned-Dropdown onchange gerufen. Bei aktiver Suche
|
||||||
|
* re-search ausfuehren, sonst Liste neu laden. */
|
||||||
|
function onBrainFiltersChanged() {
|
||||||
|
const which = brainSearchActive();
|
||||||
|
if (which === 'single') runBrainSearch();
|
||||||
|
else if (which === 'advanced') runAdvancedSearch();
|
||||||
|
else loadBrainMemoryList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtert eine Liste von Memories nach dem pinned-Dropdown-Wert.
|
||||||
|
* 'all' = alles durchlassen, 'pinned' = nur pinned, 'cold' = nur cold. */
|
||||||
|
function applyPinnedFilter(items) {
|
||||||
|
const v = document.getElementById('brain-filter-pinned')?.value || 'all';
|
||||||
|
if (v === 'pinned') return items.filter(m => m.pinned);
|
||||||
|
if (v === 'cold') return items.filter(m => !m.pinned);
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBrainSearch() {
|
async function runBrainSearch() {
|
||||||
@@ -3457,27 +3725,43 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const typeFilter = document.getElementById('brain-filter-type').value;
|
const typeFilter = document.getElementById('brain-filter-type').value;
|
||||||
// k=10 + Score-Threshold im Backend (0.30) → nur relevante Treffer.
|
const pinnedFilter = document.getElementById('brain-filter-pinned')?.value || 'all';
|
||||||
// Frueher k=20 ohne Threshold: bei kleiner DB landete fast alles
|
const mode = (document.getElementById('brain-search-mode')?.value) || 'text';
|
||||||
// als "Treffer", egal wie unaehnlich.
|
let url, modeLabel;
|
||||||
const params = new URLSearchParams({ q, k: '10', include_pinned: 'true', score_threshold: '0.30' });
|
if (mode === 'semantic') {
|
||||||
if (typeFilter) params.set('type', typeFilter);
|
// Embedder-basiert, mit Score-Threshold gegen Rauschen
|
||||||
|
const params = new URLSearchParams({ q, k: '20', include_pinned: 'true', score_threshold: '0.30' });
|
||||||
|
if (typeFilter) params.set('type', typeFilter);
|
||||||
|
url = '/api/brain/memory/search?' + params.toString();
|
||||||
|
modeLabel = '🧠 semantisch (Score ≥ 0.30)';
|
||||||
|
} else {
|
||||||
|
// Volltext-Substring (case-insensitive) — findet exakte Begriffe
|
||||||
|
const params = new URLSearchParams({ q, k: '100', include_pinned: 'true' });
|
||||||
|
if (typeFilter) params.set('type', typeFilter);
|
||||||
|
url = '/api/brain/memory/search-text?' + params.toString();
|
||||||
|
modeLabel = '📝 wortlich (Substring)';
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/brain/memory/search?' + params.toString());
|
const r = await fetch(url);
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
const hits = await r.json();
|
let hits = await r.json();
|
||||||
hits.forEach(m => { brainMemoryCache[m.id] = m; });
|
hits.forEach(m => { brainMemoryCache[m.id] = m; });
|
||||||
|
// Pinned-Filter clientseitig anwenden — Backend kennt nur include_pinned
|
||||||
|
// (all-or-none), wir brauchen aber feiner "nur pinned" / "nur cold".
|
||||||
|
const totalHits = hits.length;
|
||||||
|
hits = applyPinnedFilter(hits);
|
||||||
brainSearchIds = hits.map(m => m.id);
|
brainSearchIds = hits.map(m => m.id);
|
||||||
|
const pinnedLabel = pinnedFilter === 'pinned' ? ' · 📌 nur pinned'
|
||||||
|
: pinnedFilter === 'cold' ? ' · nur cold'
|
||||||
|
: '';
|
||||||
if (info) {
|
if (info) {
|
||||||
info.style.display = 'block';
|
info.style.display = 'block';
|
||||||
|
const filterDesc = (typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') + pinnedLabel;
|
||||||
if (hits.length === 0) {
|
if (hits.length === 0) {
|
||||||
info.innerHTML = `🔍 Keine relevanten Treffer für "${escapeHtml(q)}"` +
|
const extra = totalHits > 0 ? ` (${totalHits} Treffer ohne Pinned-Filter)` : '';
|
||||||
(typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') +
|
info.innerHTML = `🔍 Keine Treffer für "${escapeHtml(q)}"${filterDesc}${extra} · ${modeLabel}.`;
|
||||||
` (Score < 0.30). Versuche andere Begriffe oder klicke das ✕ rechts um die Suche zu schliessen.`;
|
|
||||||
} else {
|
} else {
|
||||||
info.innerHTML = `🔍 ${hits.length} Treffer für "${escapeHtml(q)}"` +
|
info.innerHTML = `🔍 ${hits.length} Treffer für "${escapeHtml(q)}"${filterDesc} · ${modeLabel}`;
|
||||||
(typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') +
|
|
||||||
` · sortiert nach Aehnlichkeit (Score ≥ 0.30)`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderBrainList(hits, true);
|
renderBrainList(hits, true);
|
||||||
@@ -3585,9 +3869,11 @@
|
|||||||
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
|
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
|
||||||
const score = withScore && typeof m.score === 'number' ? `<span style="color:#FFD60A;font-size:10px;margin-left:6px;">${m.score.toFixed(2)}</span>` : '';
|
const score = withScore && typeof m.score === 'number' ? `<span style="color:#FFD60A;font-size:10px;margin-left:6px;">${m.score.toFixed(2)}</span>` : '';
|
||||||
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
|
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
|
||||||
|
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
|
||||||
|
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
|
||||||
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
|
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
|
||||||
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
|
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
|
||||||
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}
|
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}
|
||||||
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
|
||||||
@@ -3780,6 +4066,13 @@
|
|||||||
const errEl = document.getElementById('memory-modal-error');
|
const errEl = document.getElementById('memory-modal-error');
|
||||||
errEl.style.display = 'none';
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
const attBlock = document.getElementById('memory-attachments-block');
|
||||||
|
const attHint = document.getElementById('memory-attachments-hint');
|
||||||
|
const attStatus = document.getElementById('memory-attachment-status');
|
||||||
|
if (attStatus) attStatus.textContent = '';
|
||||||
|
const attInput = document.getElementById('memory-attachment-input');
|
||||||
|
if (attInput) attInput.value = '';
|
||||||
|
|
||||||
if (id && brainMemoryCache[id]) {
|
if (id && brainMemoryCache[id]) {
|
||||||
const m = brainMemoryCache[id];
|
const m = brainMemoryCache[id];
|
||||||
titleEl.textContent = 'Memory bearbeiten';
|
titleEl.textContent = 'Memory bearbeiten';
|
||||||
@@ -3790,6 +4083,10 @@
|
|||||||
document.getElementById('memory-category').value = m.category || '';
|
document.getElementById('memory-category').value = m.category || '';
|
||||||
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
|
||||||
document.getElementById('memory-pinned').checked = !!m.pinned;
|
document.getElementById('memory-pinned').checked = !!m.pinned;
|
||||||
|
// Anhang-Block sichtbar — Liste rendern
|
||||||
|
if (attBlock) attBlock.style.display = 'block';
|
||||||
|
if (attHint) attHint.style.display = 'none';
|
||||||
|
renderMemoryAttachmentsList(m.attachments || []);
|
||||||
} else {
|
} else {
|
||||||
titleEl.textContent = 'Neue Memory';
|
titleEl.textContent = 'Neue Memory';
|
||||||
idEl.value = '';
|
idEl.value = '';
|
||||||
@@ -3799,10 +4096,96 @@
|
|||||||
document.getElementById('memory-category').value = '';
|
document.getElementById('memory-category').value = '';
|
||||||
document.getElementById('memory-tags').value = '';
|
document.getElementById('memory-tags').value = '';
|
||||||
document.getElementById('memory-pinned').checked = false;
|
document.getElementById('memory-pinned').checked = false;
|
||||||
|
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
|
||||||
|
if (attBlock) attBlock.style.display = 'none';
|
||||||
|
if (attHint) attHint.style.display = 'block';
|
||||||
}
|
}
|
||||||
modal.classList.add('open');
|
modal.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMemoryAttachmentsList(atts) {
|
||||||
|
const el = document.getElementById('memory-attachments-list');
|
||||||
|
if (!el) return;
|
||||||
|
const id = document.getElementById('memory-edit-id').value;
|
||||||
|
if (!Array.isArray(atts) || atts.length === 0) {
|
||||||
|
el.innerHTML = '<div style="color:#555570;font-size:11px;font-style:italic;">(noch keine Anhaenge)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = atts.map(a => {
|
||||||
|
const name = escapeHtml(a.name || '?');
|
||||||
|
const mime = a.mime || 'application/octet-stream';
|
||||||
|
const size = a.size ? `${(a.size / 1024).toFixed(0)} KB` : '';
|
||||||
|
const isImage = mime.startsWith('image/');
|
||||||
|
const url = `/api/brain/memory/${encodeURIComponent(id)}/attachments/${encodeURIComponent(a.name)}`;
|
||||||
|
const preview = isImage
|
||||||
|
? `<img src="${url}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;cursor:pointer;" onclick="openLightbox('image','${url}')">`
|
||||||
|
: `<span style="display:inline-block;width:32px;text-align:center;font-size:18px;">📄</span>`;
|
||||||
|
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 6px;background:#0D0D1A;border-radius:4px;">
|
||||||
|
${preview}
|
||||||
|
<a href="${url}" target="_blank" style="flex:1;min-width:0;color:#E0E0F0;text-decoration:none;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${name}">${name}</a>
|
||||||
|
<span style="color:#555570;font-size:10px;flex-shrink:0;">${escapeHtml(mime)}, ${size}</span>
|
||||||
|
<button class="btn secondary" onclick="deleteMemoryAttachment('${encodeURIComponent(a.name)}')" title="Anhang loeschen" style="padding:2px 6px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMemoryAttachments(files) {
|
||||||
|
if (!files || !files.length) return;
|
||||||
|
const id = document.getElementById('memory-edit-id').value;
|
||||||
|
if (!id) return;
|
||||||
|
const status = document.getElementById('memory-attachment-status');
|
||||||
|
let lastResult = null;
|
||||||
|
let n = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
if (status) status.textContent = `⏳ Lade ${file.name} (${(file.size/1024).toFixed(0)} KB)...`;
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file, file.name);
|
||||||
|
const r = await fetch(`/api/brain/memory/${encodeURIComponent(id)}/attachments/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const txt = await r.text();
|
||||||
|
throw new Error('HTTP ' + r.status + ': ' + txt.slice(0, 200));
|
||||||
|
}
|
||||||
|
lastResult = await r.json();
|
||||||
|
n += 1;
|
||||||
|
} catch (e) {
|
||||||
|
if (status) status.textContent = `🔴 ${file.name}: ${e.message}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastResult) {
|
||||||
|
brainMemoryCache[id] = lastResult;
|
||||||
|
renderMemoryAttachmentsList(lastResult.attachments || []);
|
||||||
|
if (status) status.textContent = `✓ ${n} Anhang${n === 1 ? '' : '/Anhaenge'} hochgeladen`;
|
||||||
|
// Eingabe-File-List reset damit erneutes Anwaehlen derselben Datei feuert
|
||||||
|
const inp = document.getElementById('memory-attachment-input');
|
||||||
|
if (inp) inp.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMemoryAttachment(filenameEncoded) {
|
||||||
|
const id = document.getElementById('memory-edit-id').value;
|
||||||
|
if (!id) return;
|
||||||
|
const name = decodeURIComponent(filenameEncoded);
|
||||||
|
if (!confirm(`Anhang "${name}" wirklich loeschen?`)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/brain/memory/${encodeURIComponent(id)}/attachments/${filenameEncoded}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const updated = await r.json();
|
||||||
|
brainMemoryCache[id] = updated;
|
||||||
|
renderMemoryAttachmentsList(updated.attachments || []);
|
||||||
|
const status = document.getElementById('memory-attachment-status');
|
||||||
|
if (status) status.textContent = `✓ "${name}" geloescht`;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Loeschen fehlgeschlagen: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeMemoryModal() {
|
function closeMemoryModal() {
|
||||||
document.getElementById('memory-modal').classList.remove('open');
|
document.getElementById('memory-modal').classList.remove('open');
|
||||||
}
|
}
|
||||||
@@ -3856,7 +4239,18 @@
|
|||||||
try {
|
try {
|
||||||
const r = await fetch('/api/brain/memory/delete/' + encodeURIComponent(id), { method: 'DELETE' });
|
const r = await fetch('/api/brain/memory/delete/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
loadBrainMemoryList();
|
// Lokalen Cache + Such-State bereinigen damit die Liste nicht den Geist
|
||||||
|
// des geloeschten Eintrags weiterzeigt.
|
||||||
|
delete brainMemoryCache[id];
|
||||||
|
if (Array.isArray(brainSearchIds)) {
|
||||||
|
brainSearchIds = brainSearchIds.filter(x => x !== id);
|
||||||
|
}
|
||||||
|
// Re-Render: bei aktiver Suche neu suchen (Filter respektieren),
|
||||||
|
// sonst die Vollliste neu vom Server holen.
|
||||||
|
const which = (typeof brainSearchActive === 'function') ? brainSearchActive() : null;
|
||||||
|
if (which === 'single') await runBrainSearch();
|
||||||
|
else if (which === 'advanced') await runAdvancedSearch();
|
||||||
|
else await loadBrainMemoryList();
|
||||||
loadBrainStatus();
|
loadBrainStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Löschen fehlgeschlagen: ' + e.message);
|
alert('Löschen fehlgeschlagen: ' + e.message);
|
||||||
|
|||||||
+62
-1
@@ -617,6 +617,26 @@ function connectRVS(forcePlain) {
|
|||||||
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
||||||
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
||||||
broadcast({ type: "mode", payload: msg.payload });
|
broadcast({ type: "mode", payload: msg.payload });
|
||||||
|
} else if (msg.type === "agent_activity") {
|
||||||
|
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
|
||||||
|
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
|
||||||
|
// Wenn gerade ein chat:final vorbei ist, unterdruecken wir trailing
|
||||||
|
// 'thinking'-Events (gleiches Schema wie alter OpenClaw-Pfad).
|
||||||
|
const activity = msg.payload?.activity || msg.activity || "idle";
|
||||||
|
if (activity !== "idle" && Date.now() - lastChatFinalAt < SETTLED_WINDOW_MS) {
|
||||||
|
// chat:final ist gerade durch — verstaubende thinking-Events ignorieren
|
||||||
|
} else {
|
||||||
|
broadcast({
|
||||||
|
type: "agent_activity",
|
||||||
|
activity,
|
||||||
|
tool: msg.payload?.tool || msg.tool || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (msg.type === "memory_saved") {
|
||||||
|
// ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool).
|
||||||
|
const m = msg.payload || {};
|
||||||
|
log("info", "rvs", `ARIA-Memory gespeichert: "${m.title}" (type=${m.type}, pinned=${m.pinned})`);
|
||||||
|
broadcast({ type: "memory_saved", payload: m });
|
||||||
} else if (msg.type === "chat_message_deleted") {
|
} else if (msg.type === "chat_message_deleted") {
|
||||||
// Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
|
// Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
|
||||||
// An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
|
// An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
|
||||||
@@ -1318,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).
|
||||||
@@ -1624,13 +1680,18 @@ const server = http.createServer((req, res) => {
|
|||||||
// Reverse-Proxy zum aria-brain Container (intern auf 8080, nicht expose'd).
|
// Reverse-Proxy zum aria-brain Container (intern auf 8080, nicht expose'd).
|
||||||
// Frontend ruft z.B. /api/brain/health → http://aria-brain:8080/health
|
// Frontend ruft z.B. /api/brain/health → http://aria-brain:8080/health
|
||||||
const targetPath = req.url.replace(/^\/api\/brain/, "");
|
const targetPath = req.url.replace(/^\/api\/brain/, "");
|
||||||
|
// Uploads brauchen laenger als die 30s default — Memory-Anhang-Endpoints
|
||||||
|
// koennen bis zu 20 MB tragen, plus chat/distill-Calls dauern manchmal
|
||||||
|
// mehr als eine Minute.
|
||||||
|
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
|
||||||
|
const timeout = isUpload ? 120000 : 60000;
|
||||||
const proxyReq = http.request({
|
const proxyReq = http.request({
|
||||||
host: "aria-brain",
|
host: "aria-brain",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
path: targetPath,
|
path: targetPath,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
timeout: 30000,
|
timeout,
|
||||||
}, (proxyRes) => {
|
}, (proxyRes) => {
|
||||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
proxyRes.pipe(res);
|
proxyRes.pipe(res);
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ services:
|
|||||||
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
|
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
|
||||||
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
|
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
|
||||||
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
|
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
|
||||||
|
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
|
||||||
|
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
|
||||||
|
# Qdrant-DB ein File-Memory aufbaut (war Auslöser fuer doppelte Truth-Source).
|
||||||
|
# Tmpfs ist beim Container-Start leer und wird beim Container-Recreate
|
||||||
|
# weggeworfen — Claude Code sieht keine alten Files mehr und das was sie
|
||||||
|
# ggf. neu schreibt landet nicht auf dem VM-Host.
|
||||||
|
tmpfs:
|
||||||
|
- /root/.claude/projects
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
|
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ Wichtige Mechanismen:
|
|||||||
|
|
||||||
### Bugs / Fixes
|
### Bugs / Fixes
|
||||||
|
|
||||||
|
- [x] **Cold Memory Crosstalk** durch Score-Threshold im Brain-Agent: Bei kleiner DB lieferte Cold-Search ungefiltert Top-5, auch wenn alle Scores < 0.2 lagen — ARIA hat das als „relevante" Info in den System-Prompt bekommen und in die Antwort eingewoben. Beispiel: Frage „hab ich ein flugzeug?" → Cold-Top war „Firmenadresse" (Score 0.094, Embedder-Noise) → ARIA antwortete „Die Adresse aus meinem Gedaechtnis ist..." ohne dass User danach gefragt hatte. Fix: Konstante `COLD_SCORE_THRESHOLD=0.30` in `agent.py` an `store.search()` durchgereicht. Konsistent mit dem `/memory/search`-HTTP-Threshold und der Diagnostic-Suche
|
||||||
|
- [x] **Diagnostic: Pinned-/Type-Filter wirkt jetzt auch bei aktiver Suche**: Vorher ignorierten `runBrainSearch`/`runAdvancedSearch` die Filter-Dropdowns komplett; Dropdown-onchange rief `loadBrainMemoryList` und brach die Suche damit ab. Fix: `applyPinnedFilter` clientseitig nach Backend-Hit, `onBrainFiltersChanged` re-search bei aktiver Suche
|
||||||
|
- [x] **Diagnostic: Memory-Liste refresht nach Delete sofort**: vorher rendere `loadBrainMemoryList` bei aktiver Such-Ansicht aus `brainMemoryCache` → der gerade geloeschte Eintrag tauchte wieder auf. Fix: Cache + brainSearchIds nach Delete bereinigen + re-search statt list
|
||||||
|
- [x] **Diagnostic: „ARIA denkt..."-Indikator wieder im Chat-Fenster**: `agent_activity`-Events von RVS wurden vom Diagnostic-Server nicht an Browser durchgereicht. Fix: Relay analog zu `mode`/`voice_ready`, mit `SETTLED_WINDOW_MS`-Schutz gegen Trailing-Events nach `chat:final`
|
||||||
|
- [x] **Memory-Suche filtert Rauschen** (score_threshold im HTTP-Endpoint + kleineres k): Vorher k=20 ohne Threshold lieferte bei kleiner DB fast alles als Treffer, auch komplettes Rauschen (z.B. „banane" → 10 false positives mit Score 0.10-0.22). Fix: `score_threshold=0.30` als Query-Param am `/memory/search`-Endpoint + Diagnostic schickt jetzt `k=10` + Threshold, „Keine Treffer"-Box wenn alle unter Score
|
||||||
|
- [x] **Cessna-Beispiel aus System-Prompt raus**: in der `memory_save`-Tool-Description stand „z.B. 'Stefan hat eine Cessna'" als fact-Beispiel. ARIA hat das (korrekt!) korrekt eingeordnet als Beispiel-Text, aber Phantom-Wissen im Prompt ist suboptimal. Fix: durch generische Aufzaehlung (Vorlieben/Besitz/Orte/Termine/Personen) ersetzt
|
||||||
|
- [x] **Claude-Code-Auto-Memory abklemmen**: Claude Code CLI hat ein eingebautes Auto-Memory das Markdown-Files in `~/.claude/projects/<project>/memory/` schreibt. Weil das CLI als ARIAs LLM lief, hat sie da ueber Wochen ihre eigene Schatten-Wissensbasis aufgebaut (cessna, persoenlichkeit, projects) — komplett parallel zur Qdrant-DB. Fix: `tmpfs`-Mount ueber `/root/.claude/projects` im Proxy-Container. Claude Code sieht beim Spawn leeres `projects/`, schreibt sie was rein landet's nur im RAM, beim Container-Recreate weg. Stefans persoenliches `~/.claude/projects/` auf der VM bleibt unangetastet
|
||||||
- [x] **Trigger-Antworten landen jetzt im Chat** (App + Diagnostic + TTS): Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft er `agent.chat()` direkt im eigenen Prozess. Die Antwort wurde nur ins Trigger-Log geschrieben — kein RVS-Broadcast, nichts sichtbar. Fix: Bridge hat jetzt einen kleinen asyncio HTTP-Listener auf Port 8090 (intern, nicht exposed). Brain pusht nach jedem Trigger-Feuer per `urllib.request.urlopen` an `http://aria-bridge:8090/internal/trigger-fired` mit `{reply, trigger_name, type, events}`. Bridge ruft `_handle_trigger_fired` → Side-Channel-Events (skill_created/trigger_created/location_tracking) + `_process_core_response` — exakt derselbe Pfad wie normale Chat-Antworten (Bubble + TTS + chat_backup)
|
- [x] **Trigger-Antworten landen jetzt im Chat** (App + Diagnostic + TTS): Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft er `agent.chat()` direkt im eigenen Prozess. Die Antwort wurde nur ins Trigger-Log geschrieben — kein RVS-Broadcast, nichts sichtbar. Fix: Bridge hat jetzt einen kleinen asyncio HTTP-Listener auf Port 8090 (intern, nicht exposed). Brain pusht nach jedem Trigger-Feuer per `urllib.request.urlopen` an `http://aria-bridge:8090/internal/trigger-fired` mit `{reply, trigger_name, type, events}`. Bridge ruft `_handle_trigger_fired` → Side-Channel-Events (skill_created/trigger_created/location_tracking) + `_process_core_response` — exakt derselbe Pfad wie normale Chat-Antworten (Bubble + TTS + chat_backup)
|
||||||
- [x] **Tool-Use im Proxy durchgereicht** (claude-max-api-proxy): Der Proxy nahm das OpenAI-`tools`-Feld an, ignorierte es aber komplett — `openai-to-cli.js` wandelte nur `messages` zu einem String, `manager.js` rief `claude --print` ohne Tools. Claude Code nutzte ihre internen Tools (Bash, Read, ...) und „simulierte" Aktionen wie `sleep 120` statt `trigger_timer` zu rufen. Fix: zwei eigene Adapter-Files unter `proxy-patches/`, die zur Container-Startzeit ueber die npm-Version kopiert werden. `openai-to-cli.js` injiziert die `tools` als `<system>`-Block mit Schema-Beschreibungen und der Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat zu verwenden; weiterhin verarbeitet sie `role=tool`-Messages als `<tool_result>`-Bloecke fuer den Loop-Replay. `cli-to-openai.js` parsed die `<tool_call>`-Bloecke aus dem Result-Text zurueck zu OpenAI `tool_calls` mit `finish_reason=tool_calls`. Mehrere Tool-Calls + Pre-Tool-Text werden korrekt aufgeteilt
|
- [x] **Tool-Use im Proxy durchgereicht** (claude-max-api-proxy): Der Proxy nahm das OpenAI-`tools`-Feld an, ignorierte es aber komplett — `openai-to-cli.js` wandelte nur `messages` zu einem String, `manager.js` rief `claude --print` ohne Tools. Claude Code nutzte ihre internen Tools (Bash, Read, ...) und „simulierte" Aktionen wie `sleep 120` statt `trigger_timer` zu rufen. Fix: zwei eigene Adapter-Files unter `proxy-patches/`, die zur Container-Startzeit ueber die npm-Version kopiert werden. `openai-to-cli.js` injiziert die `tools` als `<system>`-Block mit Schema-Beschreibungen und der Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat zu verwenden; weiterhin verarbeitet sie `role=tool`-Messages als `<tool_result>`-Bloecke fuer den Loop-Replay. `cli-to-openai.js` parsed die `<tool_call>`-Bloecke aus dem Result-Text zurueck zu OpenAI `tool_calls` mit `finish_reason=tool_calls`. Mehrere Tool-Calls + Pre-Tool-Text werden korrekt aufgeteilt
|
||||||
- [x] **Timer "in 2 Minuten" wird wieder angelegt**: ARIA hatte keine Moeglichkeit die aktuelle Zeit zu kennen — kein Bash-Tool, kein Time-Tool, kein Timestamp im System-Prompt. Die Tool-Beschreibung von `trigger_timer` empfahl sogar `date -u -d '+10 minutes'` via Bash, aber Bash gab's nicht. Folge: LLM liess den Tool-Call entweder weg oder riet einen Cutoff-Zeitstempel (Vergangenheit) → Background-Loop feuerte beim naechsten 30s-Tick sofort statt in 2min. Fix: (1) `build_time_section()` in `prompts.py` injiziert UTC + lokale Europa/Berlin-Zeit als `## Aktuelle Zeit`-Block oben im System-Prompt. (2) `trigger_timer` akzeptiert jetzt `in_seconds` als Alternative zu `fires_at` — Server rechnet den absoluten Timestamp, ARIA muss nicht ISO-rechnen
|
- [x] **Timer "in 2 Minuten" wird wieder angelegt**: ARIA hatte keine Moeglichkeit die aktuelle Zeit zu kennen — kein Bash-Tool, kein Time-Tool, kein Timestamp im System-Prompt. Die Tool-Beschreibung von `trigger_timer` empfahl sogar `date -u -d '+10 minutes'` via Bash, aber Bash gab's nicht. Folge: LLM liess den Tool-Call entweder weg oder riet einen Cutoff-Zeitstempel (Vergangenheit) → Background-Loop feuerte beim naechsten 30s-Tick sofort statt in 2min. Fix: (1) `build_time_section()` in `prompts.py` injiziert UTC + lokale Europa/Berlin-Zeit als `## Aktuelle Zeit`-Block oben im System-Prompt. (2) `trigger_timer` akzeptiert jetzt `in_seconds` als Alternative zu `fires_at` — Server rechnet den absoluten Timestamp, ARIA muss nicht ISO-rechnen
|
||||||
@@ -280,6 +287,49 @@ Skills mit Tool-Use.
|
|||||||
- [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen
|
- [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen
|
||||||
- [x] **Aktuelle-Zeit-Block im System-Prompt**: UTC + lokale Europa/Berlin-Zeit (Sommer/Winter-Heuristik) wird bei jedem Chat-Turn oben mit-injiziert, damit Timer-fires_at und Watcher mit `hour_of_day` ueberhaupt sinnvoll sind. `trigger_timer` akzeptiert zusaetzlich `in_seconds` (Server rechnet) — ARIA muss bei relativen Angaben ('in 2 Minuten') nicht selbst ISO-rechnen
|
- [x] **Aktuelle-Zeit-Block im System-Prompt**: UTC + lokale Europa/Berlin-Zeit (Sommer/Winter-Heuristik) wird bei jedem Chat-Turn oben mit-injiziert, damit Timer-fires_at und Watcher mit `hour_of_day` ueberhaupt sinnvoll sind. `trigger_timer` akzeptiert zusaetzlich `in_seconds` (Server rechnet) — ARIA muss bei relativen Angaben ('in 2 Minuten') nicht selbst ISO-rechnen
|
||||||
|
|
||||||
|
### Memory-System (Phase B Punkt 5+ Bonus)
|
||||||
|
|
||||||
|
- [x] **`memory_save`-Tool fuer ARIA**: ARIA kann selber neue Memories in die Qdrant-DB schreiben (vorher hat sie auf File-Memory ausweichen muessen weil kein Tool da war). Schema: `title`, `content`, `type` (identity/rule/preference/tool/skill/fact/conversation/reminder), optional `category`, `tags`, `pinned`. Tool-Description erklaert die Type-Wahl + sagt explizit „Du hast KEIN File-Memory mehr, schreibe nicht in `~/.claude/projects/...`". Side-Channel-Event `memory_saved` broadcastet via Bridge an App + Diagnostic — gelbe „🧠 ARIA hat etwas gemerkt"-Bubble, Auto-Refresh des Gehirn-Tabs falls offen
|
||||||
|
- [x] **Volltext-Suche im Gehirn** (`/memory/search-text`): Substring-Match (case-insensitive) ueber Title + Content + Category + Tags. Default in der Diagnostic-Suche, weil bei kleiner DB Semantic Search False-Positives ueberproduziert. Toggle „🧠 Semantisch" wechselt zu Embedder-Modus
|
||||||
|
- [x] **Advanced Search im Diagnostic-Gehirn-Tab**: aufklappbares Panel mit dynamisch erweiterbaren Suchfeldern (+ Feld Button) und UND/ODER-Operatoren zwischen ihnen. Backend-side bleibt simpel — pro Begriff einmal `/memory/search-text`, dann clientseitig per Set-Logik kombiniert. Pinned-/Type-Filter werden mit angewandt
|
||||||
|
- [x] **Mülltonne pro Chat-Bubble**: einzelne Nachrichten loeschbar (mit Confirm). Entfernt aus chat_backup.jsonl, Brain conversation.jsonl (rolling window) und allen Clients per RVS-Broadcast `chat_message_deleted`. Wichtig fuer ARIA: geloeschte Turns sind im naechsten Prompt nicht mehr im Window
|
||||||
|
- [x] **Druckansicht fuer Memories**: 📄-Button im Gehirn-Tab oeffnet eine fuer A4-Print optimierte Ansicht in neuem Tab — Strg+P → Als PDF speichern. Filter (Typ + Pinned) werden respektiert
|
||||||
|
- [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
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
- [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] **Backend-Endpoints**: GET/POST/DELETE `/memory/{id}/attachments[/...]`, plus Multipart-Upload-Variante `/upload` fuer Browser-FormData (Base64-Upload sprengt bei grossen Files Bash's ARG_MAX, multipart ist sauberer). Diagnostic-Proxy mit dynamischem Timeout (120s fuer /attachments, 60s sonst)
|
||||||
|
- [x] **Diagnostic-UI**: Memory-Modal hat Upload-Block (multiple File-Picker), Thumbnail-Vorschau bei Bildern + 📄-Icon bei Files, Klick auf Bild → Lightbox, 🗑 pro Anhang. Memory-Liste zeigt 📎N-Badge wenn N > 0 Anhaenge
|
||||||
|
- [x] **App-UI**: `memory_saved`-Bubble zeigt Anhaenge als Tap-Reihen. Tap → `file_request` ueber RVS → Bridge laedt + bei Bildern Vollbild-Modal, bei anderen Intent-Picker. `file_response`-Handler matched zusaetzlich `memorySaved.attachments[].path`
|
||||||
|
- [x] **System-Prompt-Integration**: `_attachments_line` in `prompts.py` haengt nach Hot/Cold-Memory-Eintraegen eine `📎 Anhaenge: foo.jpg (...) — Pfad: ...`-Zeile an. Bei `image/*` zusaetzlich Hinweis „Bilder kannst du via `Read <pfad>` direkt ansehen — Claude Code Read ist multi-modal-faehig"
|
||||||
|
- [x] **ARIA sieht Bilder echt** — Stufe E ohne Proxy-Patch: Claude Code's `Read`-Tool ist bereits multi-modal. ARIA ruft `Read /shared/memory-attachments/<id>/foto.jpg` → Vision-Modell beschreibt das Bild, ARIA antwortet mit den extrahierten Infos. End-to-End getestet mit Cessna-Foto: ARIA hat D-ECSW-Kennung aus dem Bild gelesen, F172-Variante erkannt (Reims-Aviation), EDWM-ICAO fuer Mariensiel selbst dazu kombiniert. **Persistent**: Bild bleibt am Memory, bei spaeteren Detail-Fragen („wie viele Fenster?") kann ARIA das Bild nochmal lesen ohne dass User es re-uploaden muss
|
||||||
|
- [x] **`memory_save` mit `attach_paths`** — ARIA kann beim Speichern selber Bilder anhaengen. Pfade aus `/shared/uploads/` (z.B. ein User-Foto aus dem Chat) werden serverseitig nach `/shared/memory-attachments/<id>/` kopiert. Pfadschutz auf Whitelist-Prefixes (kein Root-FS-Zugriff). Tool-Description weist explizit an: erst `Read <pfad>` (Vision-Beschreibung), dann `memory_save(content=<extrahierte Infos>, attach_paths=[<pfad>])` — End-to-End-Workflow in einer Tool-Call-Sequenz
|
||||||
|
|
||||||
|
### DB als Single Source of Truth
|
||||||
|
|
||||||
|
- [x] **`brain-import/` als Drop-Folder** statt aktive Saat: Inhalt komplett gitignored, nur `.gitkeep` + README im Repo. Stefan kippt MDs rein wenn er was migrieren will, klickt im Diagnostic „Migration aus brain-import/", fertig. Alte AGENT.md/BOOTSTRAP.md aus dem Repo geworfen (waren teils OpenClaw-Altlasten)
|
||||||
|
- [x] **DB-Aufraeumung**: 60 → 31 Eintraege durch Loeschen von 24 Dubletten (gleicher Title+Content unter verschiedenen IDs aus der initialen Migration) + 6 obsoleten facts (OpenClaw-Geschichte, Home-Partition-Snapshots etc.). Firmenadresse als einzige aktive `fact` behalten
|
||||||
|
- [x] **`.claude/aria-vm.env` Setup** fuer die Dev-Maschine: Claude Code auf Stefans Workstation erreicht das Brain-API ueber Diagnostic-Port 3001 via `ARIA_BRAIN_URL`. `.example` im Repo, echte Datei mit IP der VM gitignored. Damit kann Claude direkt curl gegen die DB machen ohne SSH-Tunnel
|
||||||
|
|
||||||
### Diagnostic / App Features (drumherum)
|
### Diagnostic / App Features (drumherum)
|
||||||
|
|
||||||
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
|
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"xtts_import_voice", "xtts_voice_imported",
|
"xtts_import_voice", "xtts_voice_imported",
|
||||||
"skill_created",
|
"skill_created",
|
||||||
"trigger_created",
|
"trigger_created",
|
||||||
|
"memory_saved",
|
||||||
"location_update", "location_tracking",
|
"location_update", "location_tracking",
|
||||||
"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",
|
||||||
|
"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