Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d54d37061f | |||
| a6afec0e11 | |||
| 205112021b | |||
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd | |||
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed | |||
| 3cf6308b79 | |||
| 7e5a4da659 | |||
| d27fcaf342 | |||
| 5b28a065c0 | |||
| e74e1eaf70 |
@@ -200,7 +200,7 @@ Die Diagnostic-UI hat sechs Top-Tabs:
|
||||
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
|
||||
- **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
|
||||
- **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
|
||||
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
|
||||
|
||||
@@ -319,7 +319,14 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
|
||||
- **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
|
||||
- **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.
|
||||
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
|
||||
|
||||
@@ -357,6 +364,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
||||
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
|
||||
- **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
|
||||
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
|
||||
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
|
||||
@@ -867,10 +877,12 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
||||
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
|
||||
- [x] **Phase B Punkt 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 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] **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] 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
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 10306
|
||||
versionName "0.1.3.6"
|
||||
versionCode 10404
|
||||
versionName "0.1.4.4"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.3.6",
|
||||
"version": "0.1.4.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -169,6 +169,12 @@ export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle
|
||||
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)'}
|
||||
|
||||
@@ -114,6 +114,16 @@ interface ChatMessage {
|
||||
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
||||
* wenn das chat_backup-Event vom Bridge zurueck kommt. */
|
||||
backupTs?: number;
|
||||
/** Client-seitige Eindeutigs-ID fuer Delivery-Tracking (offline-Queue,
|
||||
* ACK von Bridge, Idempotenz bei Retry). Wird beim Senden generiert und
|
||||
* durch die Bridge zurueck-gespiegelt. */
|
||||
clientMsgId?: string;
|
||||
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
|
||||
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
|
||||
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
|
||||
deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
|
||||
/** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
|
||||
sendAttempts?: number;
|
||||
}
|
||||
|
||||
// --- Konstanten ---
|
||||
@@ -236,6 +246,7 @@ const ChatScreen: React.FC = () => {
|
||||
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
|
||||
const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
|
||||
const [inboxVisible, setInboxVisible] = useState(false);
|
||||
const [showJumpDown, setShowJumpDown] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||
@@ -259,6 +270,17 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const messageIdCounter = useRef(0);
|
||||
// Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
|
||||
// nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
|
||||
// vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
|
||||
// verloren ist oder das Brain abgestuerzt — Timeout-Bubble + Reset.
|
||||
const stuckWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearStuckWatchdog = () => {
|
||||
if (stuckWatchdog.current) {
|
||||
clearTimeout(stuckWatchdog.current);
|
||||
stuckWatchdog.current = null;
|
||||
}
|
||||
};
|
||||
// ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
|
||||
// file_response wird die Datei nach dem Speichern direkt mit dem System-
|
||||
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||
@@ -270,6 +292,98 @@ const ChatScreen: React.FC = () => {
|
||||
return `msg_${Date.now()}_${messageIdCounter.current}`;
|
||||
};
|
||||
|
||||
// Eindeutige clientMsgId fuer Delivery-Tracking (Bridge-Echo, Retry,
|
||||
// Idempotenz). Format: cmsg_<ms>_<rand> — eindeutig genug fuer eine
|
||||
// 100er-Dedup-Window auf der Bridge.
|
||||
const nextClientMsgId = (): string =>
|
||||
`cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
||||
|
||||
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
|
||||
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
|
||||
const ACK_TIMEOUT_MS = 30_000;
|
||||
// Wie oft re-tryen wir bevor wir "failed" anzeigen.
|
||||
const MAX_SEND_ATTEMPTS = 3;
|
||||
// Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
|
||||
const ackTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const clearAckTimer = (cmid: string) => {
|
||||
const t = ackTimers.current.get(cmid);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
ackTimers.current.delete(cmid);
|
||||
}
|
||||
};
|
||||
|
||||
// Pending-Payloads pro clientMsgId — wir brauchen sie fuer Retry nach
|
||||
// ACK-Timeout oder nach Reconnect (offline-Queue). Liegt in einer Ref
|
||||
// damit der Inhalt Closures ueberlebt.
|
||||
const pendingPayloads = useRef<Map<string, { type: 'chat' | 'audio'; payload: Record<string, unknown> }>>(new Map());
|
||||
|
||||
// ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade)
|
||||
// die sonst auf einen veralteten Wert zugreifen wuerden.
|
||||
const connectionStateRef = useRef<ConnectionState>('disconnected');
|
||||
|
||||
// Status einer Bubble per clientMsgId aendern (Helper)
|
||||
const updateMessageStatus = useCallback(
|
||||
(cmid: string, patch: Partial<Pick<ChatMessage, 'deliveryStatus' | 'sendAttempts'>>) => {
|
||||
setMessages(prev => prev.map(m => (m.clientMsgId === cmid ? { ...m, ...patch } : m)));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Sende eine 'chat'- oder 'audio'-Nachricht an die Bridge mit ACK-Tracking.
|
||||
// - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
|
||||
// - Wenn online → status='sending', Timer fuer ACK-Erwartung.
|
||||
// - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
|
||||
const dispatchWithAck = useCallback(
|
||||
(cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
|
||||
pendingPayloads.current.set(cmid, { type, payload });
|
||||
const online = connectionStateRef.current === 'connected';
|
||||
if (!online) {
|
||||
updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
|
||||
return;
|
||||
}
|
||||
// RVS.send mit clientMsgId — Bridge spiegelt das im chat_ack zurueck
|
||||
rvs.send(type, { ...payload, clientMsgId: cmid });
|
||||
updateMessageStatus(cmid, { deliveryStatus: 'sending', sendAttempts: attempt });
|
||||
clearAckTimer(cmid);
|
||||
ackTimers.current.set(
|
||||
cmid,
|
||||
setTimeout(() => {
|
||||
ackTimers.current.delete(cmid);
|
||||
if (attempt >= MAX_SEND_ATTEMPTS) {
|
||||
updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
|
||||
console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
|
||||
} else {
|
||||
console.warn('[Chat] kein ACK fuer %s — Retry #%d', cmid, attempt + 1);
|
||||
dispatchWithAck(cmid, type, payload, attempt + 1);
|
||||
}
|
||||
}, ACK_TIMEOUT_MS),
|
||||
);
|
||||
},
|
||||
[updateMessageStatus],
|
||||
);
|
||||
|
||||
// Alle 'queued'-Nachrichten beim Reconnect rausschicken
|
||||
const flushQueuedMessages = useCallback(() => {
|
||||
setMessages(prev => {
|
||||
for (const m of prev) {
|
||||
if (m.deliveryStatus !== 'queued' || !m.clientMsgId) continue;
|
||||
const pending = pendingPayloads.current.get(m.clientMsgId);
|
||||
if (!pending) continue;
|
||||
// Versuchszaehler beibehalten (oder mit 1 starten falls leer)
|
||||
dispatchWithAck(m.clientMsgId, pending.type, pending.payload, m.sendAttempts || 1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [dispatchWithAck]);
|
||||
|
||||
// Manueller Retry nach 'failed' (tap auf das ⚠️-Icon)
|
||||
const retryFailedMessage = useCallback((cmid: string) => {
|
||||
const pending = pendingPayloads.current.get(cmid);
|
||||
if (!pending) return;
|
||||
dispatchWithAck(cmid, pending.type, pending.payload, 1);
|
||||
}, [dispatchWithAck]);
|
||||
|
||||
// TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
|
||||
// sofort greift, ohne Context- oder Event-System)
|
||||
useEffect(() => {
|
||||
@@ -375,12 +489,24 @@ const ChatScreen: React.FC = () => {
|
||||
const parsed: ChatMessage[] = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
console.log('[Chat] ${parsed.length} Nachrichten geladen');
|
||||
setMessages(parsed);
|
||||
// MERGE statt Overwrite: zwischen Mount und Load-Done koennen
|
||||
// bereits Nachrichten ankommen (User schreibt sofort, WS-Events
|
||||
// kommen vor Load-Ende). Vorher hat setMessages(parsed) diese
|
||||
// ueberschrieben → "Nachricht weg ohne Spur". Jetzt mergen wir
|
||||
// per id; lokal-gerade-hinzugefuegte schlagen Gespeichertes
|
||||
// (die sind frischer).
|
||||
setMessages(prev => {
|
||||
if (prev.length === 0) return parsed;
|
||||
const byId = new Map<string, ChatMessage>();
|
||||
for (const m of parsed) byId.set(m.id, m);
|
||||
for (const m of prev) byId.set(m.id, m);
|
||||
return [...byId.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
});
|
||||
const maxId = parsed.reduce((max, msg) => {
|
||||
const num = parseInt(msg.id.split('_').pop() || '0', 10);
|
||||
return num > max ? num : max;
|
||||
}, 0);
|
||||
messageIdCounter.current = maxId;
|
||||
messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -418,6 +544,22 @@ const ChatScreen: React.FC = () => {
|
||||
// RVS-Nachrichten abonnieren
|
||||
useEffect(() => {
|
||||
const unsubMessage = rvs.onMessage((message: RVSMessage) => {
|
||||
// chat_ack: Bridge bestaetigt Empfang einer chat/audio-Nachricht.
|
||||
// Wir markieren die Bubble als 'sent' (✓) und stoppen den ACK-Timer.
|
||||
if (message.type === ('chat_ack' as any)) {
|
||||
const cmid = (message.payload as any).clientMsgId as string | undefined;
|
||||
if (cmid) {
|
||||
clearAckTimer(cmid);
|
||||
pendingPayloads.current.delete(cmid);
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.clientMsgId === cmid && m.deliveryStatus !== 'delivered'
|
||||
? { ...m, deliveryStatus: 'sent' }
|
||||
: m
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
|
||||
if (message.type === 'file_saved') {
|
||||
const serverPath = (message.payload.serverPath as string) || '';
|
||||
@@ -749,8 +891,17 @@ const ChatScreen: React.FC = () => {
|
||||
messageId: (message.payload.messageId as string) || undefined,
|
||||
backupTs: (message.payload.backupTs as number) || undefined,
|
||||
};
|
||||
return capMessages([...prev, ariaMsg]);
|
||||
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
|
||||
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
|
||||
return capMessages([...prev, ariaMsg]).map(m =>
|
||||
m.sender === 'user'
|
||||
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
|
||||
? { ...m, deliveryStatus: 'delivered' }
|
||||
: m
|
||||
);
|
||||
});
|
||||
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
|
||||
clearStuckWatchdog();
|
||||
}
|
||||
|
||||
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
|
||||
@@ -795,6 +946,21 @@ const ChatScreen: React.FC = () => {
|
||||
setAgentActivity({ activity, tool });
|
||||
// Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
|
||||
// nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
|
||||
// Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
|
||||
// activity-Event), Timer neu starten. 180s ohne Update → Hang.
|
||||
clearStuckWatchdog();
|
||||
if (activity !== 'idle') {
|
||||
stuckWatchdog.current = setTimeout(() => {
|
||||
stuckWatchdog.current = null;
|
||||
setAgentActivity({ activity: 'idle', tool: '' });
|
||||
setMessages(prev => capMessages([...prev, {
|
||||
id: nextId(),
|
||||
sender: 'aria',
|
||||
text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 3 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
|
||||
timestamp: Date.now(),
|
||||
}]));
|
||||
}, 180_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
|
||||
@@ -838,6 +1004,7 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
const unsubState = rvs.onStateChange((state) => {
|
||||
setConnectionState(state);
|
||||
connectionStateRef.current = state;
|
||||
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
|
||||
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
|
||||
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
|
||||
@@ -845,11 +1012,26 @@ const ChatScreen: React.FC = () => {
|
||||
// Nachrichten vom Server, oder leeres Array wenn Server leer.
|
||||
if (state === 'connected') {
|
||||
rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
|
||||
// Offline-Queue flushen — alle 'queued'-Bubbles raussschicken
|
||||
flushQueuedMessages();
|
||||
} else if (state === 'disconnected') {
|
||||
// ACK-Timer cancellen, betroffene Bubbles auf 'queued' zurueck
|
||||
for (const [cmid, t] of ackTimers.current.entries()) {
|
||||
clearTimeout(t);
|
||||
ackTimers.current.delete(cmid);
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.clientMsgId === cmid && m.deliveryStatus === 'sending'
|
||||
? { ...m, deliveryStatus: 'queued' }
|
||||
: m
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initalen Status setzen
|
||||
setConnectionState(rvs.getState());
|
||||
const initialState = rvs.getState();
|
||||
setConnectionState(initialState);
|
||||
connectionStateRef.current = initialState;
|
||||
|
||||
return () => {
|
||||
unsubMessage();
|
||||
@@ -1051,26 +1233,60 @@ const ChatScreen: React.FC = () => {
|
||||
setSearchIndex(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
|
||||
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
|
||||
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
|
||||
// damit Layout sicher fertig ist.
|
||||
// Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
|
||||
// neue Nachrichten kommen waehrend Suche aktiv ist → invertedMessages
|
||||
// aendert sich, soll aber nicht den Scroll erneut triggern).
|
||||
const lastSearchScrollKey = useRef<string>('');
|
||||
// Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald
|
||||
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
|
||||
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
|
||||
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearPendingScrollRetry = () => {
|
||||
if (pendingScrollRetry.current) {
|
||||
clearTimeout(pendingScrollRetry.current);
|
||||
pendingScrollRetry.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
|
||||
// FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport →
|
||||
// Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar.
|
||||
// WICHTIG: invertedMessages bewusst NICHT in den Deps — sonst feuert das
|
||||
// Effekt bei jeder neuen ARIA-Nachricht erneut und scrollt amok.
|
||||
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
|
||||
const invertedMessagesRef = useRef(invertedMessages);
|
||||
invertedMessagesRef.current = invertedMessages;
|
||||
useEffect(() => {
|
||||
if (!searchMatchIds.length) return;
|
||||
if (!searchMatchIds.length) {
|
||||
lastSearchScrollKey.current = '';
|
||||
clearPendingScrollRetry();
|
||||
return;
|
||||
}
|
||||
const id = searchMatchIds[searchIndex];
|
||||
if (!id) return;
|
||||
const idx = invertedMessages.findIndex(m => m.id === id);
|
||||
// Eindeutiger Schluessel pro Treffer-Stop — verhindert dass identische
|
||||
// Re-Renders erneut scrollen.
|
||||
const key = `${searchIndex}:${id}`;
|
||||
if (lastSearchScrollKey.current === key) return;
|
||||
lastSearchScrollKey.current = key;
|
||||
// Neue Suche → alte Retries verwerfen
|
||||
clearPendingScrollRetry();
|
||||
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
|
||||
if (idx < 0 || !flatListRef.current) return;
|
||||
const tryScroll = () => {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
||||
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
||||
} catch {
|
||||
// wird von onScrollToIndexFailed nochmal versucht
|
||||
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
||||
}
|
||||
};
|
||||
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
|
||||
requestAnimationFrame(tryScroll);
|
||||
}, [searchIndex, searchMatchIds, invertedMessages]);
|
||||
});
|
||||
}, [searchIndex, searchMatchIds]);
|
||||
|
||||
// Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
|
||||
useEffect(() => () => {
|
||||
clearPendingScrollRetry();
|
||||
clearStuckWatchdog();
|
||||
}, []);
|
||||
|
||||
const activeSearchId = searchMatchIds[searchIndex] || '';
|
||||
const gotoSearchPrev = () => {
|
||||
@@ -1150,29 +1366,33 @@ const ChatScreen: React.FC = () => {
|
||||
const wasInterrupted = interruptAriaIfBusy();
|
||||
const location = await getCurrentLocation();
|
||||
|
||||
const cmid = nextClientMsgId();
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
|
||||
localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
||||
// An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
|
||||
rvs.send('chat', {
|
||||
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
|
||||
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
|
||||
dispatchWithAck(cmid, 'chat', {
|
||||
text,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
interrupted: wasInterrupted,
|
||||
...(location && { location }),
|
||||
});
|
||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);
|
||||
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
|
||||
|
||||
// Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
|
||||
const cancelRequest = useCallback(() => {
|
||||
setAgentActivity({ activity: 'idle', tool: '' });
|
||||
clearStuckWatchdog();
|
||||
rvs.send('cancel_request' as any, {});
|
||||
}, []);
|
||||
|
||||
@@ -1189,6 +1409,7 @@ const ChatScreen: React.FC = () => {
|
||||
if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
|
||||
if (thinking) {
|
||||
setAgentActivity({ activity: 'idle', tool: '' });
|
||||
clearStuckWatchdog();
|
||||
rvs.send('cancel_request' as any, {});
|
||||
}
|
||||
return true;
|
||||
@@ -1201,16 +1422,20 @@ const ChatScreen: React.FC = () => {
|
||||
const location = await getCurrentLocation();
|
||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
const cmid = nextClientMsgId();
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextId(),
|
||||
sender: 'user',
|
||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
||||
timestamp: Date.now(),
|
||||
audioRequestId,
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
rvs.send('audio', {
|
||||
dispatchWithAck(cmid, 'audio', {
|
||||
base64: result.base64,
|
||||
durationMs: result.durationMs,
|
||||
mimeType: result.mimeType,
|
||||
@@ -1271,13 +1496,20 @@ const ChatScreen: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Chat-Nachricht mit allen Anhaengen
|
||||
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
|
||||
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
|
||||
const cmid = messageText ? nextClientMsgId() : undefined;
|
||||
const userMsg: ChatMessage = {
|
||||
id: msgId,
|
||||
sender: 'user',
|
||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
||||
timestamp: Date.now(),
|
||||
attachments,
|
||||
...(cmid && {
|
||||
clientMsgId: cmid,
|
||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||
sendAttempts: 1,
|
||||
}),
|
||||
};
|
||||
setMessages(prev => capMessages([...prev, userMsg]));
|
||||
|
||||
@@ -1311,9 +1543,11 @@ const ChatScreen: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist)
|
||||
if (messageText) {
|
||||
rvs.send('chat', {
|
||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
||||
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
||||
// adressieren.
|
||||
if (messageText && cmid) {
|
||||
dispatchWithAck(cmid, 'chat', {
|
||||
text: messageText,
|
||||
voice: localXttsVoiceRef.current,
|
||||
speed: ttsSpeedRef.current,
|
||||
@@ -1323,7 +1557,7 @@ const ChatScreen: React.FC = () => {
|
||||
|
||||
setPendingAttachments([]);
|
||||
setInputText('');
|
||||
}, [pendingAttachments, getCurrentLocation]);
|
||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
@@ -1375,17 +1609,30 @@ const ChatScreen: React.FC = () => {
|
||||
<TouchableOpacity
|
||||
key={`${item.id}-att-${idx}`}
|
||||
style={styles.memoryAttachmentRow}
|
||||
onPress={() => {
|
||||
onPress={async () => {
|
||||
if (!a.path) return;
|
||||
if (a.localUri) {
|
||||
if (isImage) setFullscreenImage(a.localUri);
|
||||
else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || '');
|
||||
} else {
|
||||
// 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}` });
|
||||
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>
|
||||
@@ -1489,17 +1736,32 @@ const ChatScreen: React.FC = () => {
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.attachmentFile}
|
||||
onPress={() => {
|
||||
// Lokal vorhanden \u2192 direkt mit System-Intent oeffnen
|
||||
onPress={async () => {
|
||||
// Lokal vorhanden? Cache koennte geleert worden sein \u2014
|
||||
// Datei-Existenz pruefen bevor wir den Intent feuern.
|
||||
if (att.uri) {
|
||||
openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || '');
|
||||
return;
|
||||
const localPath = att.uri.replace(/^file:\/\//, '');
|
||||
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
|
||||
// gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||
// Re-Download via file_request \u2192 bei file_response wird die
|
||||
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||
if (att.serverPath) {
|
||||
autoOpenPaths.current.add(att.serverPath);
|
||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
||||
} else if (Platform.OS === 'android') {
|
||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1562,7 +1824,31 @@ const ChatScreen: React.FC = () => {
|
||||
<Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<Text style={styles.timestamp}>{time}</Text>
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.timestamp}>{time}</Text>
|
||||
{isUser && item.deliveryStatus ? (
|
||||
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
||||
<TouchableOpacity
|
||||
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||
onPress={() => retryFailedMessage(item.clientMsgId!)}
|
||||
>
|
||||
<Text style={styles.statusFailed}>{'⚠ tippen f. Retry'}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text style={
|
||||
item.deliveryStatus === 'queued' ? styles.statusQueued :
|
||||
item.deliveryStatus === 'sending' ? styles.statusSending :
|
||||
item.deliveryStatus === 'sent' ? styles.statusSent :
|
||||
/* delivered */ styles.statusDelivered
|
||||
}>
|
||||
{item.deliveryStatus === 'queued' ? '⏱' :
|
||||
item.deliveryStatus === 'sending' ? '⏳' :
|
||||
item.deliveryStatus === 'sent' ? '✓' :
|
||||
/* delivered */ '✓✓'}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1698,15 +1984,26 @@ const ChatScreen: React.FC = () => {
|
||||
ref={flatListRef}
|
||||
inverted
|
||||
data={invertedMessages}
|
||||
onScroll={(e) => {
|
||||
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
|
||||
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
|
||||
const y = e.nativeEvent.contentOffset.y;
|
||||
setShowJumpDown(y > 250);
|
||||
}}
|
||||
scrollEventThrottle={120}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
|
||||
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
|
||||
// praezise nochmal versuchen.
|
||||
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
||||
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
|
||||
// nach 300ms praezise nachzusetzen. Mehr Retries → Endlos-Cascade
|
||||
// (jeder failed Retry triggert wieder den Handler → 3, 9, 27 ...
|
||||
// Scrolls in der Pipeline = der "permanent springen"-Bug).
|
||||
const offset = info.averageItemLength * info.index;
|
||||
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
||||
setTimeout(() => {
|
||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
|
||||
}, 250);
|
||||
clearPendingScrollRetry();
|
||||
pendingScrollRetry.current = setTimeout(() => {
|
||||
pendingScrollRetry.current = null;
|
||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
||||
}, 300);
|
||||
}}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderMessage}
|
||||
@@ -1773,6 +2070,24 @@ const ChatScreen: React.FC = () => {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Jump-to-Bottom-Button — erscheint wenn man weg von der neuesten
|
||||
Nachricht gescrollt hat. Bei inverted FlatList ist scrollToOffset
|
||||
0 == neueste Nachricht visuell unten. */}
|
||||
{showJumpDown && (
|
||||
<TouchableOpacity
|
||||
style={styles.jumpDownBtn}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => {
|
||||
try {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
} catch {}
|
||||
setShowJumpDown(false);
|
||||
}}
|
||||
>
|
||||
<Text style={{color:'#fff', fontSize:18, fontWeight:'700'}}>{'↓'}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Eingabebereich */}
|
||||
<View style={styles.inputContainer}>
|
||||
{/* Datei-Buttons */}
|
||||
@@ -2111,6 +2426,35 @@ const styles = StyleSheet.create({
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-end',
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
statusQueued: {
|
||||
color: '#FFD60A', // Gelb — wartet auf Verbindung
|
||||
fontSize: 11,
|
||||
},
|
||||
statusSending: {
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 11,
|
||||
},
|
||||
statusSent: {
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: 12,
|
||||
},
|
||||
statusDelivered: {
|
||||
color: '#34C759', // Gruen — Brain hat geantwortet
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
statusFailed: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -2313,6 +2657,23 @@ const styles = StyleSheet.create({
|
||||
color: '#555570',
|
||||
fontSize: 10,
|
||||
},
|
||||
jumpDownBtn: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: 80,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#0096FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
zIndex: 100,
|
||||
},
|
||||
bubbleTrash: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
|
||||
@@ -868,7 +868,7 @@ const SettingsScreen: React.FC = () => {
|
||||
})()}
|
||||
</View>
|
||||
</Modal>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
||||
|
||||
{currentSection === null && (
|
||||
<>
|
||||
|
||||
@@ -54,6 +54,18 @@ function _newRequestId(): string {
|
||||
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;
|
||||
@@ -119,29 +131,31 @@ export const brainApi = {
|
||||
|
||||
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('limit', String(opts.limit || 500));
|
||||
return _send(`/memory/list?${qs.toString()}`);
|
||||
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 = new URLSearchParams({ q });
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
||||
qs.set('k', String(opts.k || 50));
|
||||
return _send(`/memory/search-text?${qs.toString()}`);
|
||||
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 = new URLSearchParams({ q });
|
||||
if (opts.type) qs.set('type', opts.type);
|
||||
qs.set('include_pinned', String(opts.includePinned !== false));
|
||||
qs.set('k', String(opts.k || 10));
|
||||
qs.set('score_threshold', String(opts.threshold ?? 0.30));
|
||||
return _send(`/memory/search?${qs.toString()}`);
|
||||
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. */
|
||||
|
||||
+11
-2
@@ -134,10 +134,19 @@ META_TOOLS = [
|
||||
"function": {
|
||||
"name": "trigger_watcher",
|
||||
"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). "
|
||||
"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": {
|
||||
"type": "object",
|
||||
|
||||
+68
-19
@@ -27,7 +27,12 @@ import watcher as watcher_mod
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -159,7 +164,12 @@ async def _fire(trigger: dict, 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:
|
||||
all_triggers = triggers_mod.list_triggers(active_only=True)
|
||||
except Exception as e:
|
||||
@@ -168,35 +178,74 @@ async def _tick(agent_factory) -> None:
|
||||
if not all_triggers:
|
||||
return
|
||||
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:
|
||||
if trigger.get("type") != "watcher":
|
||||
continue
|
||||
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,
|
||||
# darf der naechste Tick nicht blockieren
|
||||
asyncio.create_task(_fire(trigger, agent_factory))
|
||||
except Exception as 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:
|
||||
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
||||
global _AGENT_FACTORY
|
||||
_AGENT_FACTORY = agent_factory
|
||||
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -657,6 +657,16 @@ def triggers_list(active_only: bool = False):
|
||||
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")
|
||||
def triggers_conditions():
|
||||
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
||||
|
||||
+81
-7
@@ -25,7 +25,7 @@ import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,6 +91,12 @@ def _cpu_load_1min() -> float:
|
||||
|
||||
_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]:
|
||||
"""Letzte bekannte Position aus /shared/state/location.json.
|
||||
@@ -119,8 +125,22 @@ def _user_activity_age() -> int:
|
||||
return int(time.time() - ts)
|
||||
|
||||
|
||||
def collect_variables() -> dict[str, Any]:
|
||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
|
||||
def _near_key(lat: float, lon: float, radius_m: float) -> str:
|
||||
"""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()
|
||||
now = datetime.now()
|
||||
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.
|
||||
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
|
||||
def _near(lat: float, lon: float, radius_m: float) -> bool:
|
||||
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt."""
|
||||
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
|
||||
"""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_lon = vars_.get("current_lon")
|
||||
if cur_lat is None or cur_lon is None:
|
||||
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:
|
||||
R = 6371000.0
|
||||
phi1 = math.radians(float(cur_lat))
|
||||
@@ -194,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
|
||||
except Exception:
|
||||
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_["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_
|
||||
|
||||
|
||||
@@ -236,8 +293,25 @@ def describe_functions() -> list[dict]:
|
||||
{
|
||||
"name": "near",
|
||||
"signature": "near(lat, lon, radius_m)",
|
||||
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern "
|
||||
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.",
|
||||
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
|
||||
"Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
|
||||
"Use-Case: 'bin noch in der Naehe von X?'. "
|
||||
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
|
||||
},
|
||||
{
|
||||
"name": "entered_near",
|
||||
"signature": "entered_near(lat, lon, radius_m)",
|
||||
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
|
||||
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
|
||||
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
|
||||
"Vorwarnung bevor man am Punkt ist.",
|
||||
},
|
||||
{
|
||||
"name": "left_near",
|
||||
"signature": "left_near(lat, lon, radius_m)",
|
||||
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
|
||||
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
|
||||
"vergessen?' beim Wegfahren.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
+79
-1
@@ -25,6 +25,7 @@ import time
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -475,6 +476,13 @@ class ARIABridge:
|
||||
self.current_mode = self._load_persisted_mode()
|
||||
self.running = False
|
||||
|
||||
# Idempotenz: zuletzt gesehene clientMsgIds (App-seitig generiert).
|
||||
# Beim Reconnect/Retry sendet die App dieselbe ID nochmal — wir
|
||||
# antworten erneut mit ACK aber leiten NICHT doppelt an Brain weiter.
|
||||
# OrderedDict als FIFO mit Capping (Insertion-Order).
|
||||
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
|
||||
self._SEEN_CLIENT_MSG_LIMIT = 200
|
||||
|
||||
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt)
|
||||
self.tts_enabled = True
|
||||
self.xtts_voice = ""
|
||||
@@ -938,7 +946,12 @@ class ARIABridge:
|
||||
def _persist_location(self, location: Optional[dict]) -> None:
|
||||
"""Speichert die letzte bekannte GPS-Position fuer Watcher.
|
||||
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):
|
||||
return
|
||||
try:
|
||||
@@ -950,9 +963,31 @@ class ARIABridge:
|
||||
"lat": float(lat),
|
||||
"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:
|
||||
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:
|
||||
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
|
||||
Watcher: last_user_message_ago_sec basiert darauf."""
|
||||
@@ -1503,6 +1538,36 @@ class ARIABridge:
|
||||
except Exception:
|
||||
break
|
||||
|
||||
async def _send_chat_ack(self, client_msg_id: Optional[str]) -> None:
|
||||
"""Bestaetigt der App den Empfang einer chat/audio-Nachricht.
|
||||
App nutzt das fuer Delivery-Status (✓ = sent). Ohne ACK wuerde die
|
||||
App nach Timeout retryen — gegen Verlust bei Netz-Hicksern.
|
||||
"""
|
||||
if not client_msg_id:
|
||||
return
|
||||
await self._send_to_rvs({
|
||||
"type": "chat_ack",
|
||||
"payload": {"clientMsgId": client_msg_id},
|
||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||
})
|
||||
|
||||
def _is_duplicate_client_msg(self, client_msg_id: Optional[str]) -> bool:
|
||||
"""Prueft ob wir diese clientMsgId schon verarbeitet haben.
|
||||
Wenn ja → True (Caller soll ACK senden aber NICHT an Brain forwarden).
|
||||
Wenn nein → in den Seen-Cache aufnehmen + False zurueck.
|
||||
"""
|
||||
if not client_msg_id:
|
||||
return False
|
||||
if client_msg_id in self._seen_client_msg_ids:
|
||||
logger.info("[rvs] Idempotenz: cmid=%s bereits verarbeitet, ignoriere",
|
||||
client_msg_id)
|
||||
return True
|
||||
self._seen_client_msg_ids[client_msg_id] = time.time()
|
||||
# Capping: aelteste Eintraege rauswerfen
|
||||
while len(self._seen_client_msg_ids) > self._SEEN_CLIENT_MSG_LIMIT:
|
||||
self._seen_client_msg_ids.popitem(last=False)
|
||||
return False
|
||||
|
||||
async def _handle_rvs_message(self, raw_message: str) -> None:
|
||||
"""Verarbeitet Nachrichten von der App (via RVS).
|
||||
|
||||
@@ -1527,6 +1592,13 @@ class ARIABridge:
|
||||
sender = payload.get("sender", "")
|
||||
if sender in ("aria", "stt"):
|
||||
return
|
||||
# Delivery-ACK: immer zurueckschicken (auch bei Idempotenz-Hit),
|
||||
# damit die App den Status auf 'sent' setzen kann. Idempotenz-
|
||||
# Check VERHINDERT aber die Doppel-Weiterleitung an Brain.
|
||||
client_msg_id = payload.get("clientMsgId") or None
|
||||
await self._send_chat_ack(client_msg_id)
|
||||
if self._is_duplicate_client_msg(client_msg_id):
|
||||
return
|
||||
text = payload.get("text", "")
|
||||
# Voice-Override fuer Folgenachrichten setzen — gilt bis zum naechsten
|
||||
# chat-Event. Leerer String "" = explizit Default-Voice (override loeschen).
|
||||
@@ -2126,6 +2198,12 @@ class ARIABridge:
|
||||
|
||||
elif msg_type == "audio":
|
||||
# Audio von der App → decodieren → STT → an aria-core
|
||||
# Delivery-ACK + Idempotenz wie bei chat — App nutzt die ACKs
|
||||
# auch fuer Sprach-Bubbles (Status auf der Bubble: ✓ sent).
|
||||
client_msg_id = payload.get("clientMsgId") or None
|
||||
await self._send_chat_ack(client_msg_id)
|
||||
if self._is_duplicate_client_msg(client_msg_id):
|
||||
return
|
||||
audio_b64 = payload.get("base64", "")
|
||||
mime_type = payload.get("mimeType", "audio/mp4")
|
||||
duration_ms = payload.get("durationMs", 0)
|
||||
|
||||
@@ -297,6 +297,23 @@ Skills mit Tool-Use.
|
||||
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
|
||||
- [x] **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
|
||||
@@ -331,7 +348,6 @@ Skills mit Tool-Use.
|
||||
- [ ] Custom-Wake-Word-Upload via Diagnostic (eigene .onnx-Files ohne App-Rebuild)
|
||||
|
||||
### Architektur
|
||||
- [ ] Bilder: Claude Vision direkt nutzen (aktuell nur Dateipfad an ARIA)
|
||||
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
|
||||
- [ ] RVS Zombie-Connections endgueltig loesen
|
||||
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
|
||||
|
||||
Reference in New Issue
Block a user