Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6a68b7658 | |||
| 03edee8881 | |||
| 7093ebaf0b | |||
| b4923bc221 | |||
| 7a66752655 | |||
| b510ccd93a | |||
| bbd51406a9 | |||
| 2cd436f6e9 | |||
| 22adc91c1e | |||
| 61cf8e3bcc | |||
| 3e38f1dad3 | |||
| 635944299e | |||
| b2ac013765 | |||
| 93db6a3156 | |||
| 579a466402 | |||
| 5133f0bc2d | |||
| a476a4b734 | |||
| 11b205ddaf | |||
| 71c60ade8a | |||
| bf3dc635d9 |
@@ -219,11 +219,15 @@ Der Proxy-Container (`node:22-alpine`) installiert bei jedem Start:
|
|||||||
Danach wird der Proxy gepatcht:
|
Danach wird der Proxy gepatcht:
|
||||||
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
|
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
|
||||||
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
|
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
|
||||||
3. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
|
3. **CLI-Timeout** (sed): `DEFAULT_TIMEOUT 300000 → 1200000` (5 → 20 Min) im subprocess-manager. Multi-Tool-Workflows mit echtem Bash + curl + DB-Inserts brauchen oft 8–15 Min; 5 Min war chronisch zu kurz
|
||||||
|
4. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
|
||||||
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
|
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
|
||||||
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
|
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
|
||||||
|
- `routes.js` hookt die `assistant`-Events des Subprozesses und feuert pro `tool_use`-Block (Bash, Read, Edit, Grep, …) einen HTTP-POST an die Bridge (`/internal/agent-activity`). Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic → der Gedanken-Stream zeigt live mit was ARIA gerade tut. Fire-and-forget, fail-open — Brain-Call bricht nicht ab wenn die Bridge mal nicht da ist.
|
||||||
|
|
||||||
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren.
|
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren. Der Tool-Hook im `routes.js` macht zusaetzlich das interne Claude-Code-Werkzeug-Geschehen fuer den User sichtbar.
|
||||||
|
|
||||||
|
**Brain ↔ Bridge ist async**: `_handle_rvs_message` ruft `send_to_core` als `asyncio.create_task` statt `await` — sonst blockierte der WS-recv-Loop bis zu 20 Min und der RVS-Server (mobil.hacker-net.de) droppte die Bridge nach ~4 Min Idle-Timeout. Brain laeuft jetzt im Hintergrund-Task, RVS-Verbindung bleibt waehrend ARIA arbeitet aktiv.
|
||||||
|
|
||||||
**Wichtige Umgebungsvariablen im Proxy:**
|
**Wichtige Umgebungsvariablen im Proxy:**
|
||||||
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
|
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
|
||||||
@@ -316,7 +320,7 @@ 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, **💭 Gedanken-Stream** (zentrales Modal, zeigt live alle Tool-Calls + Phasen mit Zeitstempel und Trennlinien bei langen Pausen), End-to-End-Trace, Container-Logs
|
||||||
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. ℹ-Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
|
||||||
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
|
||||||
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …). **Drei GPS-Funktionen** mit unterschiedlicher Semantik:
|
- **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:
|
||||||
@@ -362,10 +366,11 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
- **Lokale Voice-Wahl**: Pro Geraet eigene Stimme moeglich (in Settings). Diagnostic-Wechsel ueberschreibt alle App-Wahlen.
|
||||||
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
|
- **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 — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport)
|
- **Chat-Suche**: Lupe in der Statusleiste — Highlight + Next/Prev springt zum Treffer (Bubble landet am Text-Anfang oben am Viewport). Reihenfolge **neueste zuerst** (analog WhatsApp), „Naechster" geht in die Vergangenheit. Item-Hoehen werden per `onLayout` gecached fuer praezisen Pre-Scroll auch bei langen Listen
|
||||||
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
|
- **Jump-to-Bottom-Button**: erscheint rechts unten sobald man weg von der neuesten Nachricht scrollt, ein Tap fuehrt zurueck
|
||||||
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
|
- **Delivery-Status pro User-Bubble** (WhatsApp-Style): `⏱` (queued, wartet auf Verbindung) → `⏳` (sending) → `✓` (Bridge hat ACK gesendet) → `✓✓` (ARIA hat verarbeitet). Bei Netzausfall werden Nachrichten lokal als queued gehalten und beim Reconnect automatisch geflusht. Bei drei ACK-Timeouts → `⚠ tippen f. Retry`. Idempotenz auf der Bridge (LRU ueber `clientMsgId`) verhindert Doppelte beim Retry
|
||||||
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
|
- **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
|
||||||
|
- **💭 Gedanken-Stream**: chronologisches Log was ARIA intern macht — gefuettert aus `agent_activity`-Events (denkt / 🔧 Tool-Name / schreibt / ✓ fertig). Live-Update waehrend Brain arbeitet: pro Tool-Call (Bash, Read, Edit, Grep, …) erscheint sofort ein Eintrag, durchgereicht vom claude-max-api-proxy via `proxy-patches/routes.js`-Hook. Lange Pausen zwischen Denk-Phasen werden als Trennlinie mit Minuten-Hint sichtbar. App: Icon in der Statusleiste oeffnet ein Bottom-Sheet, persistiert in AsyncStorage (capped 500). Diagnostic: identische Funktion als zentrales Modal im Chat-Test-Header
|
||||||
- **🗂️ 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)
|
- **🗂️ 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
|
- **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
|
- **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
|
||||||
@@ -375,7 +380,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
- **Einstellungen**: TTS-aktiv, F5-TTS-Voice, Pre-Roll-Buffer, Stille-Toleranz, Speicherort, Auto-Download, GPS, Verbose-Logging
|
||||||
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
- **Auto-Update**: Prueft beim Start + per Button auf neue Version, Download + Installation ueber RVS (FileProvider)
|
||||||
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
- GPS-Position (optional, mit Runtime-Permission-Request) — wird in jeden Chat/Audio-Payload mitgegeben und ist in Diagnostic als Debug-Block einblendbar
|
||||||
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App alle ~15s bzw. ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
- **GPS-Tracking (kontinuierlich)**: Toggle in Settings → Standort. Wenn aktiv, pushed die App ab 30m Bewegung ein `location_update` an die Bridge — Voraussetzung damit Watcher mit `near(lat, lon, m)` (z.B. Blitzer-Warner, Ankunft-Erinnerungen) ueberhaupt feuern koennen. **Heartbeat alle 60 s**: auch ohne Bewegung wird die letzte bekannte Position erneut an die Bridge geschickt damit der Brain-State nicht nach 5 min (NEAR_MAX_AGE_SEC) veraltet — kein extra GPS-Wakeup, akkufreundlich. ARIA selbst kann das Tracking via `request_location_tracking`-Tool an-/ausschalten und tut das automatisch wenn sie einen GPS-Watcher anlegt
|
||||||
- QR-Code Scanner fuer Token-Pairing
|
- QR-Code Scanner fuer Token-Pairing
|
||||||
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
- **ARIA-Dateien empfangen**: Wenn ARIA eine PDF/Bild/Markdown/ZIP fuer dich erstellt (Marker `[FILE: /shared/uploads/aria_*]` in der Antwort), erscheint sie als eigene Anhang-Bubble. Tippen → wird via RVS geladen + mit Android-Intent-Picker geoeffnet (PDF-Viewer, Bildbetrachter, Standard-App). Inline-Bilder aus Markdown-``-Syntax werden direkt unter dem Text gerendert (PNG/JPG via Image, SVG via react-native-svg)
|
||||||
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
|
- **Vollbild mit Pinch-Zoom**: Bilder im Vollbild-Modal sind pinch-zoombar (1x..5x), 1-Finger-Pan wenn gezoomt, Doppel-Tap toggelt 1x↔2.5x — alles ohne externe Lib
|
||||||
|
|||||||
@@ -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 10406
|
versionCode 10504
|
||||||
versionName "0.1.4.6"
|
versionName "0.1.5.4"
|
||||||
// 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.4.6",
|
"version": "0.1.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
/**
|
||||||
|
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
|
||||||
|
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
|
||||||
|
*
|
||||||
|
* Eingesetzt von SettingsScreen → Sektion "Trigger".
|
||||||
|
*
|
||||||
|
* Brain-API ueber brainApi (RVS-Brain-Proxy).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import brainApi, { Trigger } from '../services/brainApi';
|
||||||
|
|
||||||
|
const COL_ACTIVE = '#34C759';
|
||||||
|
const COL_INACTIVE = '#555570';
|
||||||
|
const COL_TIMER = '#0096FF';
|
||||||
|
const COL_WATCHER = '#FFD60A';
|
||||||
|
|
||||||
|
function relTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!t) return '—';
|
||||||
|
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||||||
|
if (diffSec < 60) return `vor ${diffSec}s`;
|
||||||
|
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||||||
|
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||||||
|
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TriggerBrowser: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<Trigger[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true); setErr(null);
|
||||||
|
brainApi.listTriggers()
|
||||||
|
.then(t => {
|
||||||
|
// Sortierung: aktive zuerst, dann nach Name
|
||||||
|
t.sort((a, b) => {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
});
|
||||||
|
setItems(t);
|
||||||
|
})
|
||||||
|
.catch(e => setErr(String(e?.message || e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const visible = items.filter(t => {
|
||||||
|
if (filter === 'active') return t.active;
|
||||||
|
if (filter === 'inactive') return !t.active;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActive = (t: Trigger) => {
|
||||||
|
brainApi.updateTrigger(t.name, { active: !t.active })
|
||||||
|
.then(() => load())
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTrigger = (t: Trigger) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Trigger löschen?',
|
||||||
|
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Löschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
brainApi.deleteTrigger(t.name)
|
||||||
|
.then(() => { setEditTrigger(null); load(); })
|
||||||
|
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Trigger }) => {
|
||||||
|
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
|
||||||
|
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
|
||||||
|
<View style={{flex: 1, marginRight: 8}}>
|
||||||
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||||
|
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
|
||||||
|
{item.type === 'watcher' && item.condition ? (
|
||||||
|
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
|
||||||
|
{item.condition}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{item.type === 'timer' && item.fires_at ? (
|
||||||
|
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
|
||||||
|
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||||
|
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={item.active}
|
||||||
|
onValueChange={() => toggleActive(item)}
|
||||||
|
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||||
|
thumbColor="#E0E0F0"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
{/* Filter-Leiste + Reload + Neu */}
|
||||||
|
<View style={s.toolbar}>
|
||||||
|
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[s.chip, filter === f && s.chipActive]}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
>
|
||||||
|
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||||
|
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||||
|
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||||
|
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={visible}
|
||||||
|
keyExtractor={t => t.name}
|
||||||
|
renderItem={renderItem}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||||
|
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{paddingBottom: 20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editTrigger ? (
|
||||||
|
<TriggerEditModal
|
||||||
|
trigger={editTrigger}
|
||||||
|
onClose={() => setEditTrigger(null)}
|
||||||
|
onSaved={() => { setEditTrigger(null); load(); }}
|
||||||
|
onDelete={() => deleteTrigger(editTrigger)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showNew ? (
|
||||||
|
<TriggerNewModal
|
||||||
|
onClose={() => setShowNew(false)}
|
||||||
|
onCreated={() => { setShowNew(false); load(); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Edit-Modal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EditProps {
|
||||||
|
trigger: Trigger;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
|
||||||
|
const [message, setMessage] = useState(trigger.message || '');
|
||||||
|
const [condition, setCondition] = useState(trigger.condition || '');
|
||||||
|
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
|
||||||
|
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
|
||||||
|
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setSaving(true);
|
||||||
|
const patch: any = { message };
|
||||||
|
if (trigger.type === 'watcher') {
|
||||||
|
patch.condition = condition;
|
||||||
|
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
|
||||||
|
patch.throttle_sec = parseInt(throttle, 10) || 3600;
|
||||||
|
} else if (trigger.type === 'timer') {
|
||||||
|
patch.fires_at = firesAt;
|
||||||
|
}
|
||||||
|
brainApi.updateTrigger(trigger.name, patch)
|
||||||
|
.then(onSaved)
|
||||||
|
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
|
||||||
|
<View style={s.modalBg}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
|
||||||
|
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{padding: 14}} nestedScrollEnabled>
|
||||||
|
<Text style={s.label}>Nachricht</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
multiline
|
||||||
|
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{trigger.type === 'watcher' ? (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Condition</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={condition}
|
||||||
|
onChangeText={setCondition}
|
||||||
|
placeholder="z.B. near(53.0, 8.5, 300)"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8}}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Check-Intervall (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={checkInterval}
|
||||||
|
onChangeText={setCheckInterval}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Throttle (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={throttle}
|
||||||
|
onChangeText={setThrottle}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={firesAt}
|
||||||
|
onChangeText={setFiresAt}
|
||||||
|
placeholder="2026-05-15T20:00:00+00:00"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={s.metaBox}>
|
||||||
|
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
|
||||||
|
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
|
||||||
|
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
|
||||||
|
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
|
||||||
|
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
<View style={s.modalFooter}>
|
||||||
|
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
|
||||||
|
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
|
||||||
|
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Neu-Modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NewProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
|
||||||
|
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [condition, setCondition] = useState('');
|
||||||
|
const [firesAt, setFiresAt] = useState('');
|
||||||
|
const [checkInterval, setCheckInterval] = useState('300');
|
||||||
|
const [throttle, setThrottle] = useState('3600');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (!name.trim() || !message.trim()) {
|
||||||
|
Alert.alert('Name und Nachricht erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const promise = ttype === 'timer'
|
||||||
|
? brainApi.createTimer({
|
||||||
|
name: name.trim(),
|
||||||
|
fires_at: firesAt.trim(),
|
||||||
|
message: message.trim(),
|
||||||
|
})
|
||||||
|
: brainApi.createWatcher({
|
||||||
|
name: name.trim(),
|
||||||
|
condition: condition.trim(),
|
||||||
|
message: message.trim(),
|
||||||
|
check_interval_sec: parseInt(checkInterval, 10) || 300,
|
||||||
|
throttle_sec: parseInt(throttle, 10) || 3600,
|
||||||
|
});
|
||||||
|
promise
|
||||||
|
.then(onCreated)
|
||||||
|
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
|
||||||
|
<View style={s.modalBg}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
|
||||||
|
<TouchableOpacity onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{padding: 14}} nestedScrollEnabled>
|
||||||
|
<Text style={s.label}>Typ</Text>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
|
||||||
|
{(['watcher', 'timer'] as const).map(t => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t}
|
||||||
|
onPress={() => setTtype(t)}
|
||||||
|
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
|
||||||
|
>
|
||||||
|
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
|
||||||
|
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={s.label}>Name (kebab-case)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="z.B. drk-kreyenbrueck-warnung"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={s.label}>Nachricht</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
multiline
|
||||||
|
placeholder="Was soll ARIA sagen?"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ttype === 'watcher' ? (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Condition</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={condition}
|
||||||
|
onChangeText={setCondition}
|
||||||
|
placeholder="z.B. entered_near(53.0, 8.5, 300)"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Text style={s.hint}>
|
||||||
|
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
|
||||||
|
</Text>
|
||||||
|
<View style={{flexDirection: 'row', gap: 8}}>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Check-Intervall (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={checkInterval}
|
||||||
|
onChangeText={setCheckInterval}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<Text style={s.label}>Throttle (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={throttle}
|
||||||
|
onChangeText={setThrottle}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||||
|
value={firesAt}
|
||||||
|
onChangeText={setFiresAt}
|
||||||
|
placeholder="2026-05-15T20:00:00+00:00"
|
||||||
|
placeholderTextColor="#555570"
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
<View style={s.modalFooter}>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
|
||||||
|
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = StyleSheet.create({
|
||||||
|
toolbar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
chipActive: {
|
||||||
|
backgroundColor: '#FFD60A',
|
||||||
|
},
|
||||||
|
iconBtn: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
err: {
|
||||||
|
color: '#FF3B30',
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
modalBg: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
backgroundColor: '#0D0D1A',
|
||||||
|
borderRadius: 12,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 600,
|
||||||
|
maxHeight: '90%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
modalFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#1E1E2E',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#1E1E2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#E0E0F0',
|
||||||
|
padding: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: '#555570',
|
||||||
|
fontSize: 11,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginTop: -4,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
metaBox: {
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 10,
|
||||||
|
marginTop: 10,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: '#8888AA',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TriggerBrowser;
|
||||||
@@ -324,7 +324,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
|
// Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
|
||||||
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
|
// unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
|
||||||
const ACK_TIMEOUT_MS = 30_000;
|
// 60s — grosszuegiger als 30s, weil langsame Brain-Calls (Multi-Tool) sonst
|
||||||
|
// 90s × 3 Retries lang die User-Bubble auf ⏳ stehen lassen wuerden. Der
|
||||||
|
// wichtige Pfad ist sowieso: agent_activity = thinking → markiert die
|
||||||
|
// Bubble sofort als 'sent' (siehe handler). Das hier ist Fallback wenn
|
||||||
|
// weder ACK noch agent_activity ankommt.
|
||||||
|
const ACK_TIMEOUT_MS = 60_000;
|
||||||
// Wie oft re-tryen wir bevor wir "failed" anzeigen.
|
// Wie oft re-tryen wir bevor wir "failed" anzeigen.
|
||||||
const MAX_SEND_ATTEMPTS = 3;
|
const MAX_SEND_ATTEMPTS = 3;
|
||||||
// Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
|
// Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
|
||||||
@@ -686,23 +691,26 @@ const ChatScreen: React.FC = () => {
|
|||||||
// gesetzt UND text leer/Placeholder)
|
// gesetzt UND text leer/Placeholder)
|
||||||
// - User-Bubbles deren clientMsgId der Server noch nicht kennt:
|
// - User-Bubbles deren clientMsgId der Server noch nicht kennt:
|
||||||
// z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
|
// z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
|
||||||
// noch laeuft. ABER: wenn der Server eine textgleiche Bubble
|
// noch laeuft. ABER: wenn der Server eine textgleiche User-
|
||||||
// im gleichen 5-Min-Fenster hat (Alter Backup-Eintrag ohne
|
// Bubble hat (egal mit welcher cmid oder ohne — z.B. wenn die
|
||||||
// clientMsgId, vor dem Bridge-Patch geschrieben), werten wir
|
// Bubble aus einer Bridge-Version vor dem clientMsgId-Patch
|
||||||
// das als Treffer und verwerfen die lokale Kopie — sonst
|
// stammt oder wenn die ts kaputt sind), werten wir das als
|
||||||
// Doppelpost: einmal als Server-Bubble (delivered) und einmal
|
// Treffer und verwerfen die lokale Kopie. Sonst Doppelpost:
|
||||||
// als lokale failed/queued mit Retry-Knopf.
|
// einmal als Server-Bubble (delivered) und einmal als lokale
|
||||||
const FIVE_MIN = 5 * 60 * 1000;
|
// failed/queued mit Retry-Knopf.
|
||||||
|
const serverUserTexts = new Set(
|
||||||
|
fromServer.filter(s => s.sender === 'user').map(s => s.text || '')
|
||||||
|
);
|
||||||
const localOnly = prev.filter(m => {
|
const localOnly = prev.filter(m => {
|
||||||
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
|
if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
|
||||||
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
|
if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
|
||||||
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
|
if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
|
||||||
const serverHasIt = fromServer.some(s =>
|
// Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
|
||||||
s.sender === 'user' &&
|
// User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
|
||||||
s.text === m.text &&
|
// kaputt etc.) — wir verwerfen die lokale Kopie. Leerer Text
|
||||||
Math.abs((s.timestamp || 0) - (m.timestamp || 0)) < FIVE_MIN,
|
// (z.B. nur Anhang) faellt nicht in den Vergleich.
|
||||||
);
|
const text = m.text || '';
|
||||||
if (serverHasIt) return false;
|
if (text && serverUserTexts.has(text)) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -1023,10 +1031,38 @@ const ChatScreen: React.FC = () => {
|
|||||||
const activity = (message.payload.activity as string) || 'idle';
|
const activity = (message.payload.activity as string) || 'idle';
|
||||||
const tool = (message.payload.tool as string) || '';
|
const tool = (message.payload.tool as string) || '';
|
||||||
setAgentActivity({ activity, tool });
|
setAgentActivity({ activity, tool });
|
||||||
|
// Implizite ACK-Bestaetigung: Brain hat angefangen zu arbeiten →
|
||||||
|
// unsere Nachricht ist offensichtlich angekommen, auch wenn das
|
||||||
|
// chat_ack aus irgendeinem Grund nicht durchkam. Alle laufenden
|
||||||
|
// ACK-Timer canceln + sending-Bubbles auf 'sent' setzen.
|
||||||
|
// Vermeidet das Symptom "Sanduhr bleibt + Timeout" bei langsamen
|
||||||
|
// Brain-Antworten (>90 s, also nach 3 ACK-Retries auf failed).
|
||||||
|
if (activity !== 'idle' && ackTimers.current.size > 0) {
|
||||||
|
for (const cmid of Array.from(ackTimers.current.keys())) {
|
||||||
|
clearAckTimer(cmid);
|
||||||
|
}
|
||||||
|
// Reference-stable: wenn keine Bubble zu aendern ist, geben wir
|
||||||
|
// prev unveraendert zurueck. Sonst triggert .map() ein neues
|
||||||
|
// Array + Re-Render, was waehrend einer aktiven Such-Scroll-
|
||||||
|
// Sequenz die FlatList-Layouts invalidiert → permanenter
|
||||||
|
// onScrollToIndexFailed-Loop.
|
||||||
|
setMessages(prev => {
|
||||||
|
const needs = prev.some(m => m.sender === 'user' && m.deliveryStatus === 'sending');
|
||||||
|
if (!needs) return prev;
|
||||||
|
return prev.map(m =>
|
||||||
|
m.sender === 'user' && m.deliveryStatus === 'sending'
|
||||||
|
? { ...m, deliveryStatus: 'sent' }
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
// In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
|
// In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
|
||||||
// Events (z.B. zwei mal 'thinking' direkt hintereinander).
|
// Events (z.B. zwei mal 'thinking' direkt hintereinander). Tool-
|
||||||
|
// Events NIE dedupen — wenn ARIA dreimal Bash hintereinander ruft,
|
||||||
|
// sollen alle drei sichtbar sein.
|
||||||
const key = `${activity}|${tool}`;
|
const key = `${activity}|${tool}`;
|
||||||
if (key !== lastThoughtKeyRef.current) {
|
const isTool = activity === 'tool';
|
||||||
|
if (isTool || key !== lastThoughtKeyRef.current) {
|
||||||
lastThoughtKeyRef.current = key;
|
lastThoughtKeyRef.current = key;
|
||||||
setThoughts(prev => {
|
setThoughts(prev => {
|
||||||
const next = [...prev, { ts: Date.now(), activity, tool }];
|
const next = [...prev, { ts: Date.now(), activity, tool }];
|
||||||
@@ -1345,15 +1381,22 @@ const ChatScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
|
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. NEUESTE ZUERST —
|
||||||
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
|
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
|
||||||
|
// Treffer ist meist schon im Viewport (kein weiter Pre-Scroll, kein
|
||||||
|
// Cold-Start-Sprung-Fail). „Naechster" geht in die Vergangenheit.
|
||||||
|
// WICHTIG: nur in chatVisibleMessages suchen — Spezial-Bubbles (Memory/
|
||||||
|
// Skill/Trigger) sind im Chat-Stream nicht sichtbar und Treffer auf die
|
||||||
|
// wuerden zu „ID nicht im FlatList → findIndex=-1 → kein Scroll"-Fail
|
||||||
|
// fuehren.
|
||||||
const searchMatchIds = useMemo(() => {
|
const searchMatchIds = useMemo(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
if (!q) return [] as string[];
|
if (!q) return [] as string[];
|
||||||
return messages
|
return chatVisibleMessages
|
||||||
.filter(m => (m.text || '').toLowerCase().includes(q))
|
.filter(m => (m.text || '').toLowerCase().includes(q))
|
||||||
.map(m => m.id);
|
.map(m => m.id)
|
||||||
}, [messages, searchQuery]);
|
.reverse();
|
||||||
|
}, [chatVisibleMessages, searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchIndex(0);
|
setSearchIndex(0);
|
||||||
@@ -1367,11 +1410,18 @@ const ChatScreen: React.FC = () => {
|
|||||||
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
|
// ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
|
||||||
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
|
// Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
|
||||||
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// Zaehler fuer fehlgeschlagene Scroll-Retries. Hartes Limit gegen
|
||||||
|
// Endlos-Loops wenn das Item-Layout aus irgendwelchen Gruenden nie
|
||||||
|
// verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die
|
||||||
|
// FlatList re-rendert).
|
||||||
|
const scrollRetryCount = useRef<number>(0);
|
||||||
|
const MAX_SCROLL_RETRIES = 3;
|
||||||
const clearPendingScrollRetry = () => {
|
const clearPendingScrollRetry = () => {
|
||||||
if (pendingScrollRetry.current) {
|
if (pendingScrollRetry.current) {
|
||||||
clearTimeout(pendingScrollRetry.current);
|
clearTimeout(pendingScrollRetry.current);
|
||||||
pendingScrollRetry.current = null;
|
pendingScrollRetry.current = null;
|
||||||
}
|
}
|
||||||
|
scrollRetryCount.current = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
|
// Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
|
||||||
@@ -1382,6 +1432,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
|
// Den aktuellen Snapshot von invertedMessages holen wir via Ref.
|
||||||
const invertedMessagesRef = useRef(invertedMessages);
|
const invertedMessagesRef = useRef(invertedMessages);
|
||||||
invertedMessagesRef.current = invertedMessages;
|
invertedMessagesRef.current = invertedMessages;
|
||||||
|
// Cache fuer echte Bubble-Hoehen, gefuettert per onLayout in
|
||||||
|
// renderMessage. Wird beim Pre-Scroll genutzt damit der grobe Sprung
|
||||||
|
// praezise landet (statt mit dem 150-px-Pauschalwert weit daneben).
|
||||||
|
const itemHeights = useRef<Map<string, number>>(new Map());
|
||||||
|
const AVG_BUBBLE_HEIGHT = 150; // Fallback fuer noch nicht gemessene Items
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchMatchIds.length) {
|
if (!searchMatchIds.length) {
|
||||||
lastSearchScrollKey.current = '';
|
lastSearchScrollKey.current = '';
|
||||||
@@ -1399,12 +1454,36 @@ const ChatScreen: React.FC = () => {
|
|||||||
clearPendingScrollRetry();
|
clearPendingScrollRetry();
|
||||||
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
|
const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
|
||||||
if (idx < 0 || !flatListRef.current) return;
|
if (idx < 0 || !flatListRef.current) return;
|
||||||
|
// Pre-Scroll: erst grob in die Naehe springen, damit FlatList die
|
||||||
|
// Bubbles in der Umgebung ueberhaupt rendert (sonst basiert
|
||||||
|
// averageItemLength im Failed-Handler nur auf den ersten ~10 Items
|
||||||
|
// und liefert einen voellig falschen Sprung).
|
||||||
|
// Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per
|
||||||
|
// onLayout) + Fallback AVG fuer noch nicht gemessene. Bei „cold start"
|
||||||
|
// ist der Cache leer → AVG fuer alle → grob. Beim zweiten Such-Versuch
|
||||||
|
// sind die Bubbles in der Naehe gemessen → genauer.
|
||||||
|
let preOffset = 0;
|
||||||
|
const inv = invertedMessagesRef.current;
|
||||||
|
for (let i = 0; i < idx; i++) {
|
||||||
|
preOffset += itemHeights.current.get(inv[i].id) || AVG_BUBBLE_HEIGHT;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: preOffset,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
// Nach kurzer Render-Pause praezise nachsetzen. 200 ms statt 80 ms —
|
||||||
|
// bei Cold-Start braucht FlatList laenger fuer das Item-Layout, das
|
||||||
|
// war Stefans „erst beim zweiten Versuch klappt's"-Bug.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
try {
|
||||||
} catch {
|
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
|
||||||
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
} catch {
|
||||||
}
|
// onScrollToIndexFailed-Handler uebernimmt den Fallback
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
});
|
});
|
||||||
}, [searchIndex, searchMatchIds]);
|
}, [searchIndex, searchMatchIds]);
|
||||||
|
|
||||||
@@ -1822,7 +1901,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
|
<View
|
||||||
|
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||||
|
onLayout={e => {
|
||||||
|
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
|
||||||
|
// die summierten Cache-Werte fuer praezisen Sprung. Bei
|
||||||
|
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
|
||||||
|
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Anhang-Vorschau */}
|
{/* Anhang-Vorschau */}
|
||||||
{item.attachments?.map((att, idx) => (
|
{item.attachments?.map((att, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
@@ -2116,6 +2203,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
inverted
|
inverted
|
||||||
data={invertedMessages}
|
data={invertedMessages}
|
||||||
|
// Mehr Items beim Mount messen → bessere averageItemLength fuer
|
||||||
|
// Such-Sprung gleich nach App-Start. Default sind 10 Items, das
|
||||||
|
// ist bei 300+ Bubbles im Backup viel zu wenig.
|
||||||
|
initialNumToRender={30}
|
||||||
|
// Mehr Items im Speicher halten (Default 21 = 10 oben + 10 unten).
|
||||||
|
// Macht scroll-to-far-away weniger anfaellig fuer Layout-Holes.
|
||||||
|
windowSize={41}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
|
// Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
|
||||||
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
|
// (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
|
||||||
@@ -2125,13 +2219,24 @@ const ChatScreen: React.FC = () => {
|
|||||||
scrollEventThrottle={120}
|
scrollEventThrottle={120}
|
||||||
onScrollToIndexFailed={(info) => {
|
onScrollToIndexFailed={(info) => {
|
||||||
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
// FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
|
||||||
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen EINMAL
|
// die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen bis zu
|
||||||
// nach 300ms praezise nachzusetzen. Mehr Retries → Endlos-Cascade
|
// MAX_SCROLL_RETRIES mal praezise nachzusetzen. Danach geben wir
|
||||||
// (jeder failed Retry triggert wieder den Handler → 3, 9, 27 ...
|
// auf — User sieht die Bubble in der ungefaehren Naehe und kann
|
||||||
// Scrolls in der Pipeline = der "permanent springen"-Bug).
|
// selber finetunen. Frueher: jeder failed Retry triggerte einen
|
||||||
|
// neuen Retry ohne Limit → "permanent springen"-Bug, vor allem
|
||||||
|
// wenn waehrenddessen setMessages die Layouts invalidierte.
|
||||||
const offset = info.averageItemLength * info.index;
|
const offset = info.averageItemLength * info.index;
|
||||||
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
|
||||||
clearPendingScrollRetry();
|
if (pendingScrollRetry.current) {
|
||||||
|
clearTimeout(pendingScrollRetry.current);
|
||||||
|
pendingScrollRetry.current = null;
|
||||||
|
}
|
||||||
|
if (scrollRetryCount.current >= MAX_SCROLL_RETRIES) {
|
||||||
|
// Aufgeben — Item ist offenbar nicht stabil renderbar
|
||||||
|
scrollRetryCount.current = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollRetryCount.current += 1;
|
||||||
pendingScrollRetry.current = setTimeout(() => {
|
pendingScrollRetry.current = setTimeout(() => {
|
||||||
pendingScrollRetry.current = null;
|
pendingScrollRetry.current = null;
|
||||||
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
|
||||||
@@ -2311,7 +2416,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
onPress={() => setThoughtsVisible(false)}
|
onPress={() => setThoughtsVisible(false)}
|
||||||
>
|
>
|
||||||
<TouchableOpacity activeOpacity={1} style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}>
|
{/* View statt TouchableOpacity, sonst konsumiert das die Touch-
|
||||||
|
Events und die FlatList laesst sich nicht scrollen.
|
||||||
|
onStartShouldSetResponder={true} blockt aber die Propagation
|
||||||
|
an das aeussere TouchableOpacity (close-on-tap-outside). */}
|
||||||
|
<View
|
||||||
|
style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
|
||||||
|
onStartShouldSetResponder={() => true}
|
||||||
|
onResponderTerminationRequest={() => false}
|
||||||
|
>
|
||||||
{/* Drag-Indicator */}
|
{/* Drag-Indicator */}
|
||||||
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
|
<View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
|
||||||
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
|
<View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
|
||||||
@@ -2390,7 +2503,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -2426,7 +2539,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
Aus diesem Chat
|
Aus diesem Chat
|
||||||
</Text>
|
</Text>
|
||||||
<ScrollView style={{paddingHorizontal:8}}>
|
<ScrollView style={{paddingHorizontal:8}} nestedScrollEnabled={true}>
|
||||||
{specials.map(m => {
|
{specials.map(m => {
|
||||||
if (m.memorySaved) {
|
if (m.memorySaved) {
|
||||||
const ms = m.memorySaved;
|
const ms = m.memorySaved;
|
||||||
@@ -2482,7 +2595,12 @@ const ChatScreen: React.FC = () => {
|
|||||||
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
<Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
|
||||||
Alle Memories aus der DB
|
Alle Memories aus der DB
|
||||||
</Text>
|
</Text>
|
||||||
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
{/* flex:1 Wrapper damit MemoryBrowser den verbleibenden Platz
|
||||||
|
bekommt (sonst rendert die FlatList intern mit 0 Hoehe und
|
||||||
|
nimmt nur was der Inhalt sagt → Scroll-Gestures verschwinden). */}
|
||||||
|
<View style={{flex:1}}>
|
||||||
|
<MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
PermissionsAndroid,
|
PermissionsAndroid,
|
||||||
|
useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
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 MemoryBrowser from '../components/MemoryBrowser';
|
||||||
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -102,6 +104,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ 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: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||||
|
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher 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;
|
||||||
@@ -118,6 +121,7 @@ const SOURCE_COLORS: Record<string, string> = {
|
|||||||
// --- Komponente ---
|
// --- Komponente ---
|
||||||
|
|
||||||
const SettingsScreen: React.FC = () => {
|
const SettingsScreen: React.FC = () => {
|
||||||
|
const winDims = useWindowDimensions();
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||||
const [manualToken, setManualToken] = useState('');
|
const [manualToken, setManualToken] = useState('');
|
||||||
const [manualHost, setManualHost] = useState('');
|
const [manualHost, setManualHost] = useState('');
|
||||||
@@ -868,7 +872,15 @@ const SettingsScreen: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} nestedScrollEnabled={true}>
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||||
|
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||||
|
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||||
|
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'}
|
||||||
|
>
|
||||||
|
|
||||||
{currentSection === null && (
|
{currentSection === null && (
|
||||||
<>
|
<>
|
||||||
@@ -1682,11 +1694,23 @@ const SettingsScreen: React.FC = () => {
|
|||||||
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
Alle Memory-Einträge aus ARIAs Vector-DB. Tippen zum Bearbeiten — mit Anhängen, pinned-Status,
|
||||||
Tags. Neue Einträge anlegen via "+ Neu".
|
Tags. Neue Einträge anlegen via "+ Neu".
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{height: 600, marginBottom: 8}}>
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
<MemoryBrowser />
|
<MemoryBrowser />
|
||||||
</View>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* === Trigger === */}
|
||||||
|
{currentSection === 'triggers' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Trigger</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||||
|
Timer (einmalige Erinnerung) + Watcher (recurring mit Condition, z.B. GPS-near). Toggle aktiv/inaktiv,
|
||||||
|
Tap zum Bearbeiten, "+ Neu" zum Anlegen.
|
||||||
|
</Text>
|
||||||
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
|
<TriggerBrowser />
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
@@ -1798,7 +1822,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
<Text style={styles.aboutTitle}>ARIA Cockpit</Text>
|
||||||
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
<Text style={styles.aboutVersion}>Version {require('../../package.json').version}</Text>
|
||||||
<Text style={styles.aboutInfo}>
|
<Text style={styles.aboutInfo}>
|
||||||
ARIA \u2014 Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
ARIA {'\u2014'} Autonomous Reasoning & Intelligence Assistant.{'\n'}
|
||||||
Stefans Kommandozentrale.{'\n'}
|
Stefans Kommandozentrale.{'\n'}
|
||||||
Gebaut mit React Native + TypeScript.
|
Gebaut mit React Native + TypeScript.
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ export interface Memory {
|
|||||||
attachments?: MemoryAttachment[];
|
attachments?: MemoryAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||||
|
export interface Trigger {
|
||||||
|
name: string;
|
||||||
|
type: 'timer' | 'watcher' | string;
|
||||||
|
active: boolean;
|
||||||
|
author?: string;
|
||||||
|
message: string;
|
||||||
|
fires_at?: string; // ISO, nur timer
|
||||||
|
condition?: string; // nur watcher
|
||||||
|
check_interval_sec?: number; // nur watcher
|
||||||
|
throttle_sec?: number; // nur watcher
|
||||||
|
fire_count?: number;
|
||||||
|
last_fired_at?: string | null;
|
||||||
|
last_checked_at?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const brainApi = {
|
export const brainApi = {
|
||||||
@@ -215,6 +233,74 @@ export const brainApi = {
|
|||||||
{ expectBinary: true, timeoutMs: 60000 },
|
{ expectBinary: true, timeoutMs: 60000 },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Triggers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Liste aller Trigger (aktive + inaktive). */
|
||||||
|
listTriggers(): Promise<Trigger[]> {
|
||||||
|
return _send('/triggers/list');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||||
|
getTrigger(name: string): Promise<Trigger> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
|
||||||
|
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
|
||||||
|
return _send('/triggers/conditions');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trigger-Logs (last N Feuerungen). */
|
||||||
|
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
|
||||||
|
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
|
||||||
|
return _send('/triggers/timer', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { author: 'app', ...body },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Watcher anlegen. */
|
||||||
|
createWatcher(body: {
|
||||||
|
name: string;
|
||||||
|
condition: string;
|
||||||
|
message: string;
|
||||||
|
check_interval_sec?: number;
|
||||||
|
throttle_sec?: number;
|
||||||
|
author?: string;
|
||||||
|
}): Promise<Trigger> {
|
||||||
|
return _send('/triggers/watcher', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { author: 'app', ...body },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
|
||||||
|
updateTrigger(name: string, body: Partial<{
|
||||||
|
active: boolean;
|
||||||
|
message: string;
|
||||||
|
condition: string;
|
||||||
|
throttle_sec: number;
|
||||||
|
check_interval_sec: number;
|
||||||
|
fires_at: string;
|
||||||
|
}>): Promise<Trigger> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Trigger loeschen. */
|
||||||
|
deleteTrigger(name: string): Promise<{ deleted: string }> {
|
||||||
|
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
timeoutMs: 15000,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default brainApi;
|
export default brainApi;
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class GpsTrackingService {
|
|||||||
private listeners: Set<Listener> = new Set();
|
private listeners: Set<Listener> = new Set();
|
||||||
// Defensive: nicht zu schnell oeffentlich togglen
|
// Defensive: nicht zu schnell oeffentlich togglen
|
||||||
private lastChangeAt = 0;
|
private lastChangeAt = 0;
|
||||||
|
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
|
||||||
|
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
|
||||||
|
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
|
||||||
|
// mehr triggert.
|
||||||
|
private lastLat: number | null = null;
|
||||||
|
private lastLon: number | null = null;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
isActive(): boolean {
|
isActive(): boolean {
|
||||||
return this.active;
|
return this.active;
|
||||||
@@ -84,6 +91,8 @@ class GpsTrackingService {
|
|||||||
(pos) => {
|
(pos) => {
|
||||||
const lat = pos.coords.latitude;
|
const lat = pos.coords.latitude;
|
||||||
const lon = pos.coords.longitude;
|
const lon = pos.coords.longitude;
|
||||||
|
this.lastLat = lat;
|
||||||
|
this.lastLon = lon;
|
||||||
rvs.send('location_update' as any, { lat, lon });
|
rvs.send('location_update' as any, { lat, lon });
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
@@ -96,6 +105,17 @@ class GpsTrackingService {
|
|||||||
fastestInterval: 10000, // (Android) max Frequenz
|
fastestInterval: 10000, // (Android) max Frequenz
|
||||||
} as any,
|
} as any,
|
||||||
);
|
);
|
||||||
|
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
|
||||||
|
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
|
||||||
|
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
|
||||||
|
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
|
||||||
|
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.lastLat != null && this.lastLon != null) {
|
||||||
|
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
this.active = true;
|
this.active = true;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
@@ -118,6 +138,10 @@ class GpsTrackingService {
|
|||||||
try { Geolocation.clearWatch(this.watchId); } catch {}
|
try { Geolocation.clearWatch(this.watchId); } catch {}
|
||||||
this.watchId = null;
|
this.watchId = null;
|
||||||
}
|
}
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|||||||
+33
-5
@@ -997,8 +997,13 @@ class ARIABridge:
|
|||||||
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
|
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
|
||||||
Wird von Diagnostic + App als History-Quelle gelesen.
|
Wird von Diagnostic + App als History-Quelle gelesen.
|
||||||
entry braucht mindestens {role, text}; ts wird ergaenzt.
|
entry braucht mindestens {role, text}; ts wird ergaenzt.
|
||||||
Returns den ts (auch fuer Bubble-Loeschen-Tracking)."""
|
Returns den ts (auch fuer Bubble-Loeschen-Tracking).
|
||||||
ts = int(asyncio.get_event_loop().time() * 1000)
|
|
||||||
|
WICHTIG: ts ist UNIX-ms (time.time()*1000), NICHT loop-time.
|
||||||
|
Loop-time ist Container-monotonic — bei jedem Restart wieder 0.
|
||||||
|
Das brach die App-History-Sortierung weil App-side Date.now()
|
||||||
|
(echtes UNIX-ms) mit Bridge-Container-Uptime gemischt wurde."""
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
try:
|
try:
|
||||||
line = {"ts": ts}
|
line = {"ts": ts}
|
||||||
line.update(entry)
|
line.update(entry)
|
||||||
@@ -2519,17 +2524,22 @@ class ARIABridge:
|
|||||||
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
status = await asyncio.get_event_loop().run_in_executor(None, _do_request)
|
||||||
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
logger.info("[cancel] Diagnostic /api/cancel: %s", status)
|
||||||
|
|
||||||
async def _emit_activity(self, activity: str, tool: str = "") -> None:
|
async def _emit_activity(self, activity: str, tool: str = "", force: bool = False) -> None:
|
||||||
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
"""Sendet agent_activity an die App — nur wenn sich der State geaendert hat.
|
||||||
|
|
||||||
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
|
Trailing Agent-Events nach chat:final werden 3s lang unterdrueckt
|
||||||
(nur 'idle' kommt immer durch)."""
|
(nur 'idle' kommt immer durch).
|
||||||
|
|
||||||
|
force=True: kein State-Dedup — wird vom Proxy-Tool-Hook genutzt
|
||||||
|
damit auch wiederholte gleiche Tool-Aufrufe (z.B. 3x Bash
|
||||||
|
hintereinander) im Gedanken-Stream als eigene Eintraege sichtbar
|
||||||
|
bleiben."""
|
||||||
if activity != "idle" and self._last_chat_final_at > 0:
|
if activity != "idle" and self._last_chat_final_at > 0:
|
||||||
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
|
since_final = asyncio.get_event_loop().time() - self._last_chat_final_at
|
||||||
if since_final < 3.0:
|
if since_final < 3.0:
|
||||||
return
|
return
|
||||||
state = (activity, tool)
|
state = (activity, tool)
|
||||||
if state == self._last_activity_state:
|
if not force and state == self._last_activity_state:
|
||||||
return
|
return
|
||||||
self._last_activity_state = state
|
self._last_activity_state = state
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
@@ -2677,6 +2687,24 @@ class ARIABridge:
|
|||||||
self._handle_trigger_fired(reply, trigger_name, ttype, events)
|
self._handle_trigger_fired(reply, trigger_name, ttype, events)
|
||||||
)
|
)
|
||||||
await _send_response(writer, 200, {"ok": True})
|
await _send_response(writer, 200, {"ok": True})
|
||||||
|
elif method == "POST" and path == "/internal/agent-activity":
|
||||||
|
# Vom Proxy gefeuert bei jedem Claude-Code-tool_use-Event
|
||||||
|
# (Bash, Read, Edit, Grep, ...). Wir spiegeln das als
|
||||||
|
# RVS agent_activity an App+Diagnostic damit der Gedanken-
|
||||||
|
# Stream live mitlaufen kann.
|
||||||
|
try:
|
||||||
|
data = json.loads(body.decode("utf-8", "ignore"))
|
||||||
|
except Exception as exc:
|
||||||
|
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
|
||||||
|
return
|
||||||
|
tool = (data.get("tool") or "").strip()
|
||||||
|
if not tool:
|
||||||
|
await _send_response(writer, 400, {"error": "tool erforderlich"})
|
||||||
|
return
|
||||||
|
# Force-emit (kein Dedup): User soll JEDEN Tool-Call sehen
|
||||||
|
# selbst wenn derselbe Name zweimal in Folge kommt.
|
||||||
|
asyncio.create_task(self._emit_activity("tool", tool, force=True))
|
||||||
|
await _send_response(writer, 200, {"ok": True})
|
||||||
elif method == "POST" and path == "/internal/delete-chat-message":
|
elif method == "POST" and path == "/internal/delete-chat-message":
|
||||||
try:
|
try:
|
||||||
data = json.loads(body.decode("utf-8", "ignore"))
|
data = json.loads(body.decode("utf-8", "ignore"))
|
||||||
|
|||||||
+102
-7
@@ -968,11 +968,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-triggers -->
|
</div><!-- /tab-triggers -->
|
||||||
|
|
||||||
<!-- Trigger-Create Modal -->
|
<!-- Trigger-Create/Edit Modal -->
|
||||||
<div class="modal-overlay" id="trigger-modal">
|
<div class="modal-overlay" id="trigger-modal">
|
||||||
<div class="modal-box" style="max-width:600px;">
|
<div class="modal-box" style="max-width:600px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Neuer Trigger</h3>
|
<h3 id="trigger-modal-title">Neuer Trigger</h3>
|
||||||
<button class="modal-close" onclick="closeTriggerModal()">×</button>
|
<button class="modal-close" onclick="closeTriggerModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:16px;">
|
<div class="modal-body" style="padding:16px;">
|
||||||
@@ -986,8 +986,16 @@
|
|||||||
|
|
||||||
<!-- Timer-spezifisch -->
|
<!-- Timer-spezifisch -->
|
||||||
<div id="trigger-timer-fields">
|
<div id="trigger-timer-fields">
|
||||||
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
<!-- Create-mode: relativ („in X Minuten ab jetzt") -->
|
||||||
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
<div id="trigger-timer-create-fields">
|
||||||
|
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
|
||||||
|
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
|
||||||
|
</div>
|
||||||
|
<!-- Edit-mode: absoluter ISO-Timestamp (UTC) -->
|
||||||
|
<div id="trigger-timer-edit-fields" style="display:none;">
|
||||||
|
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Feuert am (ISO, UTC)</label>
|
||||||
|
<input type="text" id="trigger-timer-fires-at" placeholder="2026-05-15T20:00:00+00:00" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Watcher-spezifisch -->
|
<!-- Watcher-spezifisch -->
|
||||||
@@ -1008,7 +1016,7 @@
|
|||||||
</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;">
|
||||||
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
|
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
|
||||||
<button class="btn" onclick="saveTrigger()">Anlegen</button>
|
<button class="btn" id="trigger-modal-save-btn" onclick="saveTrigger()">Anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2252,9 +2260,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushThought(activity, tool) {
|
function pushThought(activity, tool) {
|
||||||
// Dedup gegen direkt aufeinanderfolgende identische Events
|
// Dedup gegen direkt aufeinanderfolgende identische Events. Tool-
|
||||||
|
// Events NIE dedupen — drei Bash-Calls in Folge sollen drei Eintraege
|
||||||
|
// ergeben, nicht einen.
|
||||||
const key = `${activity}|${tool || ''}`;
|
const key = `${activity}|${tool || ''}`;
|
||||||
if (key === lastThoughtKey) return;
|
if (activity !== 'tool' && key === lastThoughtKey) return;
|
||||||
lastThoughtKey = key;
|
lastThoughtKey = key;
|
||||||
thoughtStream.push({ ts: Date.now(), activity, tool: tool || '' });
|
thoughtStream.push({ ts: Date.now(), activity, tool: tool || '' });
|
||||||
if (thoughtStream.length > MAX_THOUGHTS) thoughtStream = thoughtStream.slice(-MAX_THOUGHTS);
|
if (thoughtStream.length > MAX_THOUGHTS) thoughtStream = thoughtStream.slice(-MAX_THOUGHTS);
|
||||||
@@ -3099,6 +3109,7 @@
|
|||||||
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
|
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
|
||||||
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
|
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
|
||||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||||
|
<button class="btn secondary" onclick="openTriggerEdit('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#0096FF;border-color:#0096FF;">✎ Bearbeiten</button>
|
||||||
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
|
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
|
||||||
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
|
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3136,10 +3147,21 @@
|
|||||||
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
|
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// null = Create-Modus, string = Edit-Modus (Name der bearbeiteten Bubble)
|
||||||
|
let editingTriggerName = null;
|
||||||
|
|
||||||
async function openTriggerCreate() {
|
async function openTriggerCreate() {
|
||||||
|
editingTriggerName = null;
|
||||||
|
document.getElementById('trigger-modal-title').textContent = 'Neuer Trigger';
|
||||||
|
document.getElementById('trigger-modal-save-btn').textContent = 'Anlegen';
|
||||||
|
document.getElementById('trigger-type').disabled = false;
|
||||||
|
document.getElementById('trigger-name').disabled = false;
|
||||||
|
document.getElementById('trigger-timer-create-fields').style.display = '';
|
||||||
|
document.getElementById('trigger-timer-edit-fields').style.display = 'none';
|
||||||
document.getElementById('trigger-type').value = 'timer';
|
document.getElementById('trigger-type').value = 'timer';
|
||||||
document.getElementById('trigger-name').value = '';
|
document.getElementById('trigger-name').value = '';
|
||||||
document.getElementById('trigger-timer-minutes').value = '10';
|
document.getElementById('trigger-timer-minutes').value = '10';
|
||||||
|
document.getElementById('trigger-timer-fires-at').value = '';
|
||||||
document.getElementById('trigger-condition').value = '';
|
document.getElementById('trigger-condition').value = '';
|
||||||
document.getElementById('trigger-check-interval').value = '300';
|
document.getElementById('trigger-check-interval').value = '300';
|
||||||
document.getElementById('trigger-throttle').value = '3600';
|
document.getElementById('trigger-throttle').value = '3600';
|
||||||
@@ -3168,6 +3190,52 @@
|
|||||||
|
|
||||||
function closeTriggerModal() {
|
function closeTriggerModal() {
|
||||||
document.getElementById('trigger-modal').classList.remove('open');
|
document.getElementById('trigger-modal').classList.remove('open');
|
||||||
|
editingTriggerName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit-Modus: Modal mit existierenden Trigger-Werten fuellen. */
|
||||||
|
async function openTriggerEdit(name) {
|
||||||
|
const t = triggersCache.find(x => x.name === name);
|
||||||
|
if (!t) { alert('Trigger nicht in cache, lade neu...'); loadTriggers(); return; }
|
||||||
|
editingTriggerName = name;
|
||||||
|
document.getElementById('trigger-modal-title').textContent = 'Trigger bearbeiten — ' + name;
|
||||||
|
document.getElementById('trigger-modal-save-btn').textContent = 'Speichern';
|
||||||
|
// Type + Name sind im Edit-Modus nicht aenderbar
|
||||||
|
document.getElementById('trigger-type').value = t.type;
|
||||||
|
document.getElementById('trigger-type').disabled = true;
|
||||||
|
document.getElementById('trigger-name').value = t.name;
|
||||||
|
document.getElementById('trigger-name').disabled = true;
|
||||||
|
// Timer: relative-Minutes-Feld aus, absoluter ISO-Feld an
|
||||||
|
document.getElementById('trigger-timer-create-fields').style.display = 'none';
|
||||||
|
document.getElementById('trigger-timer-edit-fields').style.display = '';
|
||||||
|
document.getElementById('trigger-timer-fires-at').value = t.fires_at || '';
|
||||||
|
// Watcher-Felder vorbefuellen
|
||||||
|
document.getElementById('trigger-condition').value = t.condition || '';
|
||||||
|
document.getElementById('trigger-check-interval').value = String(t.check_interval_sec || 300);
|
||||||
|
document.getElementById('trigger-throttle').value = String(t.throttle_sec || 3600);
|
||||||
|
document.getElementById('trigger-message').value = t.message || '';
|
||||||
|
document.getElementById('trigger-modal-error').style.display = 'none';
|
||||||
|
onTriggerTypeChange();
|
||||||
|
// Variablen-Hinweis fuer Watcher auch im Edit-Modus
|
||||||
|
if (t.type === 'watcher') {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/brain/triggers/conditions');
|
||||||
|
const d = await r.json();
|
||||||
|
const info = document.getElementById('trigger-vars-info');
|
||||||
|
if (info) {
|
||||||
|
const vars = (d.variables || []).map(v =>
|
||||||
|
`<code>${escapeHtml(v.name)}</code>=${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
|
||||||
|
).join(' · ');
|
||||||
|
const fns = (d.functions || []).map(f =>
|
||||||
|
`<code>${escapeHtml(f.signature)}</code> — ${escapeHtml(f.desc)}`
|
||||||
|
).join('<br>');
|
||||||
|
info.innerHTML =
|
||||||
|
'<strong>Variablen:</strong> ' + vars +
|
||||||
|
(fns ? '<br><br><strong>Funktionen:</strong><br>' + fns : '');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
document.getElementById('trigger-modal').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTrigger() {
|
async function saveTrigger() {
|
||||||
@@ -3179,6 +3247,33 @@
|
|||||||
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
|
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
|
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
try {
|
try {
|
||||||
|
// ── EDIT-MODUS ──────────────────────────────────────────
|
||||||
|
if (editingTriggerName) {
|
||||||
|
const patch = { message };
|
||||||
|
if (ttype === 'watcher') {
|
||||||
|
const condition = document.getElementById('trigger-condition').value.trim();
|
||||||
|
if (!condition) { errEl.textContent = 'Condition fehlt.'; errEl.style.display = 'block'; return; }
|
||||||
|
patch.condition = condition;
|
||||||
|
patch.check_interval_sec = parseInt(document.getElementById('trigger-check-interval').value, 10) || 300;
|
||||||
|
patch.throttle_sec = parseInt(document.getElementById('trigger-throttle').value, 10) || 3600;
|
||||||
|
} else if (ttype === 'timer') {
|
||||||
|
const fa = document.getElementById('trigger-timer-fires-at').value.trim();
|
||||||
|
if (fa) patch.fires_at = fa;
|
||||||
|
}
|
||||||
|
const r = await fetch('/api/brain/triggers/' + encodeURIComponent(editingTriggerName), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const txt = await r.text();
|
||||||
|
throw new Error('HTTP ' + r.status + ': ' + txt.slice(0, 200));
|
||||||
|
}
|
||||||
|
closeTriggerModal();
|
||||||
|
loadTriggers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ── CREATE-MODUS ────────────────────────────────────────
|
||||||
let url, body;
|
let url, body;
|
||||||
if (ttype === 'timer') {
|
if (ttype === 'timer') {
|
||||||
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
|
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
|
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 1200000;/' $$DIST/subprocess/manager.js &&
|
||||||
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js &&
|
||||||
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js &&
|
||||||
|
cp /proxy-patches/routes.js $$DIST/server/routes.js &&
|
||||||
claude-max-api"
|
claude-max-api"
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
- ~/.claude:/root/.claude # Claude CLI Auth (Credentials in /root/.claude/.credentials.json)
|
||||||
|
|||||||
@@ -350,6 +350,32 @@ Skills mit Tool-Use.
|
|||||||
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
|
- [x] **Delivery-Handshake (WhatsApp-Style)**: pro User-Bubble ein lokaler `clientMsgId` + `deliveryStatus` (queued/sending/sent/delivered/failed). Bridge sendet `chat_ack` zurueck (✓ sent) und schreibt die ID ins `chat_backup.jsonl`. ARIA-Reply markiert alle vorigen User-Bubbles als delivered (✓✓). LRU-Idempotenz auf der Bridge (200 cmids) verhindert Doppelte beim Retry. Offline-Queue: Nachrichten im Flugmodus bleiben lokal als ⏱-queued, beim Reconnect feuert `flushQueuedMessages`. ACK-Timeout 30 s, bis zu 3 Retries, danach ⚠ + Tap-fuer-Retry
|
||||||
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
|
- [x] **Offline-Bubble verschwand nach Reconnect (Race)**: parallel laufen `chat_history_request` und `flushQueuedMessages` beim Reconnect; die History-Antwort kam an bevor die Bridge die Bubble persistiert hatte → Merge ersetzte den lokalen Stand → Bubble weg (war aber in Diagnostic drin). Fix: Bridge spiegelt `clientMsgId` im `chat_backup.jsonl`, App-Merge dedupt per cmid und behaelt lokale Bubbles deren ID der Server noch nicht kennt
|
||||||
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
|
- [x] **Doppel-Bubble nach Retry**: Backup-Eintraege von vor dem cmid-Patch hatten keine `clientMsgId` — Server-Bubble (ohne cmid) und lokale failed-Bubble (mit cmid) standen beide im Merge. Plus ACK-Timer lief gelegentlich weiter obwohl die Bubble schon `delivered` war → Retry pushte den Status zurueck auf `sending`. Fix: Merge faellt zusaetzlich auf `text+timestamp`-Heuristik im 5-Min-Fenster zurueck; `dispatchWithAck` prueft per Ref ob die Bubble inzwischen `delivered` ist und cancelt dann; bei ARIA-Reply werden alle laufenden ACK-Timer gecleart
|
||||||
|
- [x] **chat_backup ts war Container-Uptime statt UNIX-ms**: `_append_chat_backup` nutzte `asyncio.get_event_loop().time()` (Monotonic, bei jedem Restart wieder 0) statt `time.time()`. Folge: Server-Bubbles mit ts wie 394M (6 min Uptime) wurden in der App-History neben App-side Bubbles mit Date.now() (1.778e12) sortiert — Hello-Kitty-Konversation von gestern landete chronologisch nach heutigen Karten-Routen, neue Nachrichten verschwanden unter dem 500er-Cap. Plus: Doppelpost-Schutz griff nicht weil das 5-Min-ts-Fenster bei 1.7 Bio ms Diff nie zutraf. Fix: Bridge schreibt jetzt UNIX-ms, Migration-Script `tools/migrate_chat_backup_ts.py` repariert vorhandene jsonl (284/299 ts umgeschrieben auf der VM, Datei-Reihenfolge bleibt). App-Merge dedupt zusaetzlich per blossem Text-Match (ohne ts-Diff) — schuetzt auch gegen vorhandene lokale Duplikate
|
||||||
|
- [x] **User-Bubble ⏳→failed bei langsamen ARIA-Antworten**: ACK-Timer (30 s × 3 Retries) lief durch obwohl Brain laengst arbeitete — wenn `chat_ack` aus irgendwelchen Gruenden nicht durchkam (RVS-Frame verloren etc.), wurde die Bubble nach 90 s auf failed gesetzt obwohl die Antwort gleich danach kam. Fix: jedes `agent_activity != idle`-Event ist impliziter ACK — Brain wuerde nicht arbeiten wenn es die Nachricht nicht haette. Beim ersten non-idle Event werden alle laufenden ACK-Timer gecanceled und sending-Bubbles auf 'sent' gesetzt. ACK_TIMEOUT_MS zusaetzlich von 30 s auf 60 s als Backup
|
||||||
|
- [x] **Gedanken-Stream Modal scrollte nicht**: innerer `TouchableOpacity` (eigentlich nur fuer close-on-tap-outside-Schutz) hat alle Touch-Events konsumiert. Fix: durch `View` mit `onStartShouldSetResponder={true}` + `onResponderTerminationRequest={false}` ersetzt — blockt Tap-Propagation ohne Scrolls der Children zu verschlucken
|
||||||
|
|
||||||
|
### Brain-Hang: Multi-Tool-Timeouts + RVS-Block + Skill-Aggressivitaet
|
||||||
|
|
||||||
|
- [x] **Skill-Erstellung aggressiver als gewollt**: Prompt sagte „Harte Regel — IMMER Skill anlegen wenn pip-Library noetig". ARIA hat das wortwoertlich genommen und bei einer simplen pdf-extract-Frage sofort `skill_create` aufgerufen → Brain 12 Min blockiert (venv 2 min + pip install 10 min Timeout in `skills.py`). App zeigt „ARIA denkt", Bridge emitted nach 5 Min Timeout idle, User ohne Antwort. Fix in `prompts.py`: „Goldene Regel: NIE ungefragt Skills anlegen" + nur bei expliziter Anfrage („mach daraus einen Skill") und auch dann nur wenn die 4 Kriterien (wiederkehrend / nicht-trivial / parametrisierbar / wiederverwendbar) zutreffen. Greift auf der VM nach `docker compose restart aria-brain` ohne Re-Build
|
||||||
|
- [x] **Brain-Timeouts 5 Min → 20 Min**: drei verkettete 5-Min-Timeouts (Bridge `urlopen`, Brain `proxy_client`, Proxy `DEFAULT_TIMEOUT` im claude-max-api-proxy npm-Modul) feuerten exakt gleichzeitig. Live in den Logs nachvollzogen: ein Proxy-Call brauchte 4m51s und wurde von der Bridge auf den Sekundenbruchteil genau gekappt. Aufgabenstellungen wie Karten-Rekonstruktion mit 10+ curl-Calls oder PDF-Verarbeitung brauchen aber locker 8–15 Min. Fix: alle drei Timeouts auf 1200 s, plus dritter sed-Patch im docker-compose proxy-Service (`DEFAULT_TIMEOUT = 300000 → 1200000`). App-Stuck-Watchdog auf 1260 s (21 Min, knapp drueber)
|
||||||
|
- [x] **RVS-Block waehrend Brain-Call** (mobil.hacker-net.de:444 droppt nach 4 Min idle): `async for raw_message in ws: await _handle_rvs_message(...)` — das await blockierte den recv-Loop solange `send_to_core` lief. Die websockets-Lib beantwortete Pings im Hintergrund, aber der RVS-Server zaehlt nur echte App-Frames und droppt sonst die Verbindung. Symptom: App+Diagnostic zeigten „abgebrochen" obwohl Brain noch arbeitete. Fix: `send_to_core` als `asyncio.create_task` statt `await` — RVS-recv-Loop bleibt frei, neue Messages werden weiter verarbeitet, Verbindung bleibt lebendig
|
||||||
|
|
||||||
|
### Gedanken-Stream + Live-Tool-Events
|
||||||
|
|
||||||
|
- [x] **Gedanken-Stream in App + Diagnostic**: chronologisches Log was ARIA intern macht, gefuettert aus `agent_activity`-Events (thinking/tool/assistant/idle). Bleibt zwischen Denk-Phasen stehen, lange Pausen sichtbar als Trennlinie mit Minuten-Hint. App: 💭-Icon in der Statusleiste oeffnet Bottom-Sheet mit chronologischer Liste, 🗑-Confirm zum Leeren. Diagnostic: 💭 Gedanken-Button im Chat-Test-Header oeffnet zentrales Modal, Live-Update wenn neue Eintraege kommen (autoscroll ans Ende). Persistierung in AsyncStorage / localStorage, capped auf 500 Eintraege
|
||||||
|
- [x] **Live-Tool-Events vom Proxy**: dritter Proxy-Patch (`proxy-patches/routes.js`) hookt Claude-CLI `assistant`-Events — bei jedem `tool_use`-Block (Bash, Read, Edit, Grep, ...) wird per HTTP-POST an die Bridge gemeldet. Bridge spiegelt das als `agent_activity tool=<name>` an RVS-Clients. Vorher kam pro Brain-Call nur EIN „💭 denkt" am Anfang und EIN „✓ fertig" am Ende — jetzt sieht man **live** in beiden UIs wie ARIA durch die Tools haengt. Hook ist fire-and-forget (ARIA_TOOL_HOOK_URL Env-Variable, default http://aria-bridge:8090/internal/agent-activity)
|
||||||
|
|
||||||
|
### Such-Sprung-Praezision + Such-Reihenfolge
|
||||||
|
|
||||||
|
- [x] **Such-Sprung kalt nach App-Start**: scrollToIndex landete bei langen Listen weit daneben (Cessna-Treffer → Sprung zur Oberhausen-Bubble 15 Stellen daneben). `info.averageItemLength` aus `onScrollToIndexFailed` basierte auf den ersten ~10 gerenderten Items — bei sehr unterschiedlichen Bubble-Hoehen (Voice ~70 px, lange ARIA-Antworten 400+ px) eine grottige Schaetzung. Fix: `itemHeights`-Ref-Map wird per `onLayout` in `renderMessage` gefuettert; Pre-Scroll summiert echte gemessene Hoehen (Fallback `AVG_BUBBLE_HEIGHT=150` fuer noch nicht gerenderte). Plus `initialNumToRender: 30` (Default 10) und `windowSize: 41` (Default 21) → mehr Items beim Mount gemessen
|
||||||
|
- [x] **Such-Scroll Endlos-Loop (Wiederkehr)**: `onScrollToIndexFailed` retried unbegrenzt — jeder failed Retry rief den Handler erneut auf → neuer Timer → fail → loop. Plus: `setMessages` im `agent_activity`-Handler rief `prev.map()` auch wenn nichts zu aendern war → neues Array bei jedem Tool-Event → FlatList-Layouts invalidiert mitten in der Scroll-Sequenz. Fix: hartes `MAX_SCROLL_RETRIES = 3` plus `prev.some()`-Check vor `.map()` damit reference-stable bei No-Op
|
||||||
|
- [x] **Such-Treffer in Spezial-Bubbles**: `searchMatchIds` suchte in `messages` (alle Bubbles inkl. Memory/Skill/Trigger), aber gescrollt wird in `invertedMessages` die diese filtert → `findIndex=-1` → kein Scroll, alter Pre-Scroll-Stand bleibt sichtbar. Fix: `searchMatchIds` aus `chatVisibleMessages`. Memory-Inhalte sind weiterhin ueber die 🗂️-Inbox erreichbar
|
||||||
|
- [x] **Such-Reihenfolge: neueste zuerst** (WhatsApp/Telegram-analog): User ist visuell unten im Chat, der erste Treffer ist meist schon im Viewport ohne weiten Pre-Scroll. „Naechster" geht in die Vergangenheit. Plus Pre-Scroll-Wartezeit 80→200 ms damit FlatList beim ersten Versuch Render-Zeit hat
|
||||||
|
|
||||||
|
### Misc App-Polish
|
||||||
|
|
||||||
|
- [x] **About-Text rendete `—` literal**: JSX-Text-Knoten interpretieren keine JS-String-Escapes — `—` blieb als Backslash-u-Sequenz sichtbar. Fix: `{'—'}` als JS-Expression-Block
|
||||||
|
- [x] **GPS-Heartbeat fuer stationaere User**: `watchPosition` mit `distanceFilter: 30` sendet keine Updates ohne 30 m Bewegung. Stefan stationaer → nach initialer Position keine weiteren Updates → Brain verwirft Position nach `NEAR_MAX_AGE_SEC=300` als veraltet → `near()`-Watcher feuern nie. Fix: zusaetzlich zum watchPosition laeuft ein `setInterval(60s)` Heartbeat der die zuletzt empfangene Position erneut sendet. Kein extra GPS-Wakeup, akkufreundlich — und Brain-State bleibt frisch auch ohne Bewegung
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* ARIA-patched API Route Handlers
|
||||||
|
*
|
||||||
|
* Erweiterung der npm-Version von claude-max-api-proxy:
|
||||||
|
* - Bei jedem Claude-CLI-`assistant`-Event mit tool_use-Block (Bash, Read,
|
||||||
|
* Edit, Grep, …) wird ein HTTP-POST an die Bridge gefeuert
|
||||||
|
* (ARIA_TOOL_HOOK_URL, default http://aria-bridge:8090/internal/agent-activity).
|
||||||
|
* Bridge spiegelt das als RVS `agent_activity` an App+Diagnostic →
|
||||||
|
* Gedanken-Stream zeigt live was ARIA gerade tool-maessig macht.
|
||||||
|
* - Fire-and-forget, fail-open. Wenn die Bridge nicht antwortet, bricht
|
||||||
|
* der Brain-Call NICHT ab.
|
||||||
|
*
|
||||||
|
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
|
||||||
|
* (siehe docker-compose.yml proxy-Block).
|
||||||
|
*/
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import http from "http";
|
||||||
|
import { ClaudeSubprocess } from "../subprocess/manager.js";
|
||||||
|
import { openaiToCli } from "../adapter/openai-to-cli.js";
|
||||||
|
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
|
||||||
|
|
||||||
|
const TOOL_HOOK_URL = process.env.ARIA_TOOL_HOOK_URL
|
||||||
|
|| "http://aria-bridge:8090/internal/agent-activity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pusht einen Tool-Use-Event an die Bridge. Fire-and-forget — keine Awaits,
|
||||||
|
* keine Fehler nach oben. Logged Fehler still.
|
||||||
|
*/
|
||||||
|
function _emitToolEvent(toolName) {
|
||||||
|
if (!toolName) return;
|
||||||
|
try {
|
||||||
|
const u = new URL(TOOL_HOOK_URL);
|
||||||
|
const body = JSON.stringify({ tool: String(toolName) });
|
||||||
|
const req = http.request({
|
||||||
|
method: "POST",
|
||||||
|
hostname: u.hostname,
|
||||||
|
port: u.port || 80,
|
||||||
|
path: u.pathname,
|
||||||
|
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
||||||
|
timeout: 2000,
|
||||||
|
}, (res) => { res.resume(); });
|
||||||
|
req.on("error", () => {});
|
||||||
|
req.on("timeout", () => req.destroy());
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
} catch (_) { /* niemals weiterwerfen */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hookt die `assistant`-Events des Subprozesses. Jedes assistant-Message
|
||||||
|
* kann mehrere content-Bloecke haben — tool_use-Bloecke pushen wir live.
|
||||||
|
*/
|
||||||
|
function _attachToolHook(subprocess) {
|
||||||
|
subprocess.on("assistant", (message) => {
|
||||||
|
try {
|
||||||
|
const blocks = message?.message?.content || [];
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b && b.type === "tool_use" && b.name) {
|
||||||
|
_emitToolEvent(b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { /* fail-open */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle POST /v1/chat/completions
|
||||||
|
*
|
||||||
|
* Main endpoint for chat requests, supports both streaming and non-streaming
|
||||||
|
*/
|
||||||
|
export async function handleChatCompletions(req, res) {
|
||||||
|
const requestId = uuidv4().replace(/-/g, "").slice(0, 24);
|
||||||
|
const body = req.body;
|
||||||
|
const stream = body.stream === true;
|
||||||
|
try {
|
||||||
|
// Validate request
|
||||||
|
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message: "messages is required and must be a non-empty array",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
code: "invalid_messages",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert to CLI input format
|
||||||
|
const cliInput = openaiToCli(body);
|
||||||
|
const subprocess = new ClaudeSubprocess();
|
||||||
|
// ARIA-Patch: Tool-Use-Events live an die Bridge weiterleiten.
|
||||||
|
// Greift fuer beide Branches (stream + non-stream).
|
||||||
|
_attachToolHook(subprocess);
|
||||||
|
if (stream) {
|
||||||
|
await handleStreamingResponse(req, res, subprocess, cliInput, requestId);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await handleNonStreamingResponse(res, subprocess, cliInput, requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("[handleChatCompletions] Error:", message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle streaming response (SSE)
|
||||||
|
*
|
||||||
|
* IMPORTANT: The Express req.on("close") event fires when the request body
|
||||||
|
* is fully received, NOT when the client disconnects. For SSE connections,
|
||||||
|
* we use res.on("close") to detect actual client disconnection.
|
||||||
|
*/
|
||||||
|
async function handleStreamingResponse(req, res, subprocess, cliInput, requestId) {
|
||||||
|
// Set SSE headers
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Request-Id", requestId);
|
||||||
|
// CRITICAL: Flush headers immediately to establish SSE connection
|
||||||
|
// Without this, headers are buffered and client times out waiting
|
||||||
|
res.flushHeaders();
|
||||||
|
// Send initial comment to confirm connection is alive
|
||||||
|
res.write(":ok\n\n");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let isFirst = true;
|
||||||
|
let lastModel = "claude-sonnet-4";
|
||||||
|
let isComplete = false;
|
||||||
|
// Handle actual client disconnect (response stream closed)
|
||||||
|
res.on("close", () => {
|
||||||
|
if (!isComplete) {
|
||||||
|
// Client disconnected before response completed - kill subprocess
|
||||||
|
subprocess.kill();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Handle streaming content deltas
|
||||||
|
subprocess.on("content_delta", (event) => {
|
||||||
|
const text = event.event.delta?.text || "";
|
||||||
|
if (text && !res.writableEnded) {
|
||||||
|
const chunk = {
|
||||||
|
id: `chatcmpl-${requestId}`,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: lastModel,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
role: isFirst ? "assistant" : undefined,
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
finish_reason: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Handle final assistant message (for model name)
|
||||||
|
subprocess.on("assistant", (message) => {
|
||||||
|
lastModel = message.message.model;
|
||||||
|
});
|
||||||
|
subprocess.on("result", (_result) => {
|
||||||
|
isComplete = true;
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
// Send final done chunk with finish_reason
|
||||||
|
const doneChunk = createDoneChunk(requestId, lastModel);
|
||||||
|
res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("error", (error) => {
|
||||||
|
console.error("[Streaming] Error:", error.message);
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
error: { message: error.message, type: "server_error", code: null },
|
||||||
|
})}\n\n`);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("close", (code) => {
|
||||||
|
// Subprocess exited - ensure response is closed
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
if (code !== 0 && !isComplete) {
|
||||||
|
// Abnormal exit without result - send error
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
error: { message: `Process exited with code ${code}`, type: "server_error", code: null },
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Start the subprocess
|
||||||
|
subprocess.start(cliInput.prompt, {
|
||||||
|
model: cliInput.model,
|
||||||
|
sessionId: cliInput.sessionId,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("[Streaming] Subprocess start error:", err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle non-streaming response
|
||||||
|
*/
|
||||||
|
async function handleNonStreamingResponse(res, subprocess, cliInput, requestId) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let finalResult = null;
|
||||||
|
subprocess.on("result", (result) => {
|
||||||
|
finalResult = result;
|
||||||
|
});
|
||||||
|
subprocess.on("error", (error) => {
|
||||||
|
console.error("[NonStreaming] Error:", error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
subprocess.on("close", (code) => {
|
||||||
|
if (finalResult) {
|
||||||
|
res.json(cliResultToOpenai(finalResult, requestId));
|
||||||
|
}
|
||||||
|
else if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: `Claude CLI exited with code ${code} without response`,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// Start the subprocess
|
||||||
|
subprocess
|
||||||
|
.start(cliInput.prompt, {
|
||||||
|
model: cliInput.model,
|
||||||
|
sessionId: cliInput.sessionId,
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
type: "server_error",
|
||||||
|
code: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle GET /v1/models
|
||||||
|
*
|
||||||
|
* Returns available models
|
||||||
|
*/
|
||||||
|
export function handleModels(_req, res) {
|
||||||
|
res.json({
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "claude-opus-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-haiku-4",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handle GET /health
|
||||||
|
*
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
export function handleHealth(_req, res) {
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
provider: "claude-code-cli",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=routes.js.map
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: chat_backup.jsonl ts-Werte von Container-Uptime-ms auf UNIX-ms umstellen.
|
||||||
|
|
||||||
|
Hintergrund: vor dem Fix nutzte _append_chat_backup() `asyncio.get_event_loop().time()`,
|
||||||
|
was Container-Monotonic ist (bei Restart wieder 0). Mischte sich mit App-side
|
||||||
|
`Date.now()` (echtes UNIX-ms) → falsche Sortierung in der App-History.
|
||||||
|
|
||||||
|
Strategie: ts < 1e12 (keine UNIX-ms) werden umgeschrieben. Anker = file-mtime,
|
||||||
|
decay 60 Sekunden pro Eintrag rueckwaerts. Datei-Reihenfolge bleibt erhalten
|
||||||
|
(append-only war chronologisch korrekt, nur ts-Werte waren Unsinn).
|
||||||
|
|
||||||
|
Vorhandene UNIX-ms-Eintraege (file_deleted-Marker, neue Eintraege ab Bridge-Fix)
|
||||||
|
werden unveraendert gelassen.
|
||||||
|
|
||||||
|
Idempotent: zweimal laufen lassen ist sicher — beim zweiten Mal sind alle ts
|
||||||
|
schon UNIX-ms und werden nicht angefasst.
|
||||||
|
|
||||||
|
Backup: schreibt erst chat_backup.jsonl.bak, dann atomar replace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
UNIX_MS_THRESHOLD = 10 ** 12 # < 1e12 ms = vor 2001 = unrealistisch fuer UNIX
|
||||||
|
GAP_SECONDS = 60 # 1 Eintrag pro Minute rueckwaerts ab mtime
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Datei nicht da: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
raw = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
entries = []
|
||||||
|
for raw_line in raw:
|
||||||
|
s = raw_line.strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(s))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ueberspringe kaputte Zeile: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
print("Datei leer")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_mtime_ms = int(os.path.getmtime(path) * 1000)
|
||||||
|
n = len(entries)
|
||||||
|
fixed = 0
|
||||||
|
|
||||||
|
# Wir bauen einen Ersatz-ts (file_mtime - gap*minutes_back) nur fuer
|
||||||
|
# Eintraege deren ts < UNIX_MS_THRESHOLD. file_deleted etc. mit echtem
|
||||||
|
# UNIX-ms bleiben unangetastet.
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
ts = entry.get("ts", 0)
|
||||||
|
if not isinstance(ts, (int, float)) or ts < UNIX_MS_THRESHOLD:
|
||||||
|
# Synth-ts vergeben: aelteste = mtime - n*gap, neueste = mtime
|
||||||
|
new_ts = file_mtime_ms - (n - 1 - i) * GAP_SECONDS * 1000
|
||||||
|
entry["ts"] = new_ts
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
if fixed == 0:
|
||||||
|
print(f"Nichts zu migrieren ({n} Eintraege, alle ts schon UNIX-ms)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
bak = path.with_suffix(path.suffix + ".bak")
|
||||||
|
shutil.copy2(path, bak)
|
||||||
|
print(f"Backup: {bak}")
|
||||||
|
|
||||||
|
# Atomic rewrite
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
for entry in entries:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
print(f"Migration fertig: {fixed}/{n} ts umgeschrieben")
|
||||||
|
print(f" aelteste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[0]['ts'] / 1000))}")
|
||||||
|
print(f" neueste neu : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(entries[-1]['ts'] / 1000))}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
default = Path("/var/lib/docker/volumes/aria-agent_aria-shared/_data/config/chat_backup.jsonl")
|
||||||
|
path = Path(sys.argv[1]) if len(sys.argv) > 1 else default
|
||||||
|
migrate(path)
|
||||||
Reference in New Issue
Block a user