Compare commits

..

11 Commits

Author SHA1 Message Date
duffyduck 8491fb2af7 release: bump version to 0.1.2.3 2026-05-11 23:48:00 +02:00
duffyduck f61864282e docs: README + issue.md mit Token-Metrics + Diagnostic-Tabs aktualisiert
README.md
  - Phase B erledigt-Liste um zwei Punkte erweitert:
    Token/Call-Metrics + Multi-Select-Datei-Manager
  - Diagnostic-Tabs in der Installations-Sektion aktualisiert
    (5 Tabs statt 4 — Skills als eigener Tab)
  - Diagnostic-Sektion weiter unten: Tabs-Liste komplett ueberholt,
    Token-Metrics als eigener Bullet in "Was zusaetzlich drin steckt"

issue.md
  - "Token/Call-Metrics" als erledigter Punkt in "Diagnostic/App-Features"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:45:37 +02:00
duffyduck b2f7d6dda2 feat(brain+diagnostic): Token/Call-Metrics mit Subscription-Plan-Tracking
Stefan hat den Max 5x Plan (~\$90-100/Monat), ungefaehres Limit 225 Calls pro
5h-Fenster fuer Sonnet. Damit nicht in eine Tool-Loop-Schleife laufen ohne
es zu merken: kleine Metrics-Pipeline, sichtbar in der Diagnostic.

aria-brain/metrics.py
  Append-only JSONL Logger unter /data/metrics.jsonl. Pro Claude-Call eine
  Zeile {ts, model, in, out} mit Token-Schaetzung (chars/4, Anthropic-
  Heuristik). aggregate(window) zaehlt die letzten N Sekunden.
  Auto-Rotate bei 50k Zeilen → 25k behalten (~70 KB/Monat bei 1k Calls/Tag,
  Cap also weit oben).

aria-brain/proxy_client.py
  chat_full() ruft am Ende metrics.log_call(model, messages_in, reply).
  Failed/exception-Pfade loggen nicht (sonst false positives).

aria-brain/main.py
  GET /metrics/calls → {h1, h5, h24, d30}, jedes Window mit calls,
  tokens_in, tokens_out, by_model.

diagnostic/index.html
  Neue Card "Token / Calls" im Gehirn-Tab. Plan-Dropdown
  (Pro / Max 5x / Max 20x / Custom), localStorage-persistiert. 4 Metric-
  Zellen fuer 1h/5h/24h/30d mit Calls + Tokens. Progress-Bar oben zeigt
  5h-Counter gegen Plan-Limit. Warn-Klassen: gelb bei 80%, rot bei 90%.
  Auto-Refresh alle 30s wenn Gehirn-Tab offen, plus bei Tab-Wechsel.
  Info-Modal erklaert die Limits + dass HTTP-Call != User-Frage (Tool-Use
  kann pro Frage bis zu 8 Calls verursachen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:43:56 +02:00
duffyduck eeedcc4781 docs: README + issue.md — Phase B komplett (Punkte 2/3/4 + drumherum)
README.md
  - Phase A erledigt-Liste erweitert: Info-Buttons, Multi-Select+ZIP, App-Datei-Manager
  - Neue Sektion "Phase B — Brain mit Memory + Loop + Skills" mit allen 3
    Hauptpunkten (Migration / Conversation-Loop / Skills-System) abgehakt
  - Plus drumherum: Sprachmodell-Setting, App-Chat-Sync, Chat-Suche Next/Prev
  - Phase 2: "Erste Skills bauen lassen" — primaerer naechster Schritt

issue.md
  - "Brain — Phase B (komplett)" Sektion oben — strukturiert in
    Infrastruktur / Memory / Conversation-Loop / Skills / Diagnostic+App
  - Alle Phase-B-Punkte abgehakt, mit Detail-Bullets
  - Offen-Liste: Tool-Use-Verifikation (claude-max-api-proxy tools-Param)
    explizit als ToDo, plus "Erste Skills bauen lassen" als Use-Case

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:36:50 +02:00
duffyduck 5cf8cab5bd feat: App-Chat-Suche mit Next/Prev + Diagnostic Sprachausgabe-Layout
App Chat-Suche umgebaut von Filter zu Highlight+Navigation
  Vorher: searchQuery filtert die FlatList, zeigt nur Treffer.
  Jetzt:  Suche filtert NICHT mehr, alle Nachrichten bleiben sichtbar.
          Treffer wird gelb (FFD60A) umrandet, FlatList scrollt automatisch
          dorthin.
  - Suchleiste: Input + Counter "N/M" + ▲ + ▼ + ✕
  - ▲ / ▼ navigieren chronologisch durch alle Matches (zyklisch)
  - searchMatchIds via useMemo, searchIndex separates State
  - scrollToIndex mit viewPosition: 0.4 (Treffer landet im oberen Drittel)
  - onScrollToIndexFailed Fallback nach 200ms (Layout noch nicht fertig)

Diagnostic Sprachausgabe-Layout
  Export/Import-Buttons wandern aus dem Section-Header in den Details-Block
  neben "Anwenden" (Stefan's Wunsch). Header zeigt nur noch den Titel.
  File-Input bleibt versteckt im Section-Top, wird vom neuen Button-Block
  unten ueber click() getriggert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:35:02 +02:00
duffyduck 3ae9e19524 feat: Datei-Manager Multi-Select + Bulk-Download (ZIP) + Bulk-Delete
Diagnostic + App bekommen Mehrfach-Auswahl im Datei-Manager. Mehr als eine
Datei ausgewaehlt → Download als ZIP. Genau eine ausgewaehlt → einzeln.
Bulk-Delete loescht alle markierten in einem Rutsch.

diagnostic/Dockerfile
  zip via apk add — fuer das ZIP-Streaming im /api/files-download-zip.

diagnostic/server.js
  POST /api/files-download-zip   Body: {paths:[...]} → spawnt 'zip -j -q -',
                                  Pipes stdout in Response. Whitelist auf
                                  /shared/uploads/.
  POST /api/files-delete-batch   Body: {paths:[...]} → loescht alle, broadcastet
                                  file_deleted pro Pfad an Browser + RVS.

diagnostic/index.html
  filesSelected Set + Checkbox-UI pro Datei + "Alle markieren". Wenn 2+
  ausgewaehlt: POST an /api/files-download-zip, Browser saugt das als
  Blob runter. Bei 1: normaler Single-Download.

bridge/aria_bridge.py
  file_delete_batch_request    → ruft Diagnostic /api/files-delete-batch,
                                 antwortet mit file_delete_batch_response.
  file_zip_request {paths,reqId} → ruft Diagnostic /api/files-download-zip,
                                   base64-kodiert, capped auf 30 MB,
                                   sendet file_zip_response.

rvs/server.js
  ALLOWED_TYPES: file_delete_batch_request/response, file_zip_request/response.

android/src/screens/SettingsScreen.tsx
  fileManagerSelected Set + Checkbox-UI pro Datei + "Alle markieren"-Zeile
  oben. Bulk-Bar oben mit count, "⬇ ZIP" / "⬇ Download" (je nach Anzahl),
  und "🗑 Löschen". ZIP-Response landet base64 → RNFS in Downloads-Folder
  (aria-files-<timestamp>.zip), Toast mit Pfad.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:30:40 +02:00
duffyduck 0ec4b00879 feat: App-Chat-Sync — verpasste Nachrichten + chat_cleared Live-Update
Zwei zusammenhaengende Bugs:
  1. App aktualisierte nicht wenn die Diagnostic "Konversation komplett
     zuruecksetzen" gedrueckt hat — die App hatte den lokalen Stand weiter
  2. Nachrichten die kamen waehrend die App offline/geschlossen war,
     wurden nicht nachgeladen

Loesung: chat_backup.jsonl wird wieder geschrieben (Bridge statt Diagnostic,
weil OpenClaw-Code-Pfad tot ist) und dient als Server-Truth fuer App+Diagnostic.

bridge/aria_bridge.py
  _append_chat_backup() schreibt jeden Turn (User + ARIA) als JSONL-Zeile
  in /shared/config/chat_backup.jsonl. Trigger: send_to_core (User) +
  _process_core_response (Assistant, inkl. file-Attachments).

  _read_chat_backup_since(since_ms, limit) liest die Datei, filtert auf
  ts > since_ms, gibt max limit neueste zurueck. Honoriert file_deleted-Marker.

  Neuer RVS-Handler chat_history_request {since, limit?} → antwortet mit
  chat_history_response {messages: [...], since}.

diagnostic/server.js
  /api/chat-history-clear broadcastet jetzt zusaetzlich chat_cleared via
  RVS (sendToRVS_raw), damit App ihre lokale Liste auch leert. Vorher nur
  Browser-Clients via broadcast() — App war aussen vor.

rvs/server.js
  ALLOWED_TYPES um chat_history_request, chat_history_response, chat_cleared.

android/src/screens/ChatScreen.tsx
  - Bei (re)connect: AsyncStorage 'aria_chat_last_sync' lesen → send
    chat_history_request {since}
  - Handler chat_history_response: incoming → ChatMessage[] mappen,
    Attachments aus 'files'-Array rekonstruieren, mergen (Dedup via timestamp),
    lastSync hochziehen
  - Handler chat_cleared: setMessages([]) + AsyncStorage 'chat_messages' +
    'last_sync' weg
  - Bei jeder eingehenden chat-Message: 'aria_chat_last_sync' updaten damit
    Reconnect nicht doppelt nachzieht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:24:52 +02:00
duffyduck b6b4b1b4d9 fix(diagnostic): loadBrainStatus updated jetzt beide Cards (Main + Gehirn)
Symptom: Main-Tab "ARIA BRAIN"-Card blieb auf "Lade..." haengen. Klick auf
"Status pruefen" tat scheinbar nichts.

Ursache: loadBrainStatus() suchte nur brain-status (Gehirn-Tab). Die Card
im Main-Tab hat aber andere IDs (brain-dot + brain-status-short + brain-error),
die wurden nirgends mehr befuellt seit updateState() das nicht mehr macht.

Fix: loadBrainStatus update jetzt BEIDE Anzeigen synchron — kompakte Main-Card
mit Dot/Status/Error UND die ausfuehrliche Gehirn-Tab-Zeile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:07:42 +02:00
duffyduck 950a9d009c feat(diagnostic): Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
Reusable Info-Modal-System: kleines (ℹ)-Button neben Ueberschriften, beim
Klick oeffnet ein Modal mit ausfuehrlicher Erklaerung. Fuer die 4 wichtigsten
Brain-Konzepte sind die Texte vor-definiert (INFO_TEXTS dict).

  - Gehirn — Status     online/offline, N Memories, Qdrant-Endpoint
  - Konversation        Rolling Window 50, Schwelle 60, Destillat-Logik,
                        Hinweis warum chat_backup ≠ conversation.jsonl
  - Memories            Hot vs. Cold, alle 8 Typen erklaert, semantische Suche
  - Bootstrap           Die drei Wege (Migration / Snapshot / Komplett-Gehirn)

Plus inline-ℹ-Button neben der "Konversation: N Turns"-Zeile in der
Status-Card, damit man dort wo's relevant ist sofort die Erklaerung findet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:03:47 +02:00
duffyduck 693542ef19 fix(diagnostic): Brain-Card live + Runtime-Konfig-Text + Konversation-Reset macht beides
Drei zusammenhaengende Fixes — alle aus der OpenClaw-Umstellung uebrig.

Brain-Card im Main-Tab
  - updateState() schrieb "aria-core entfernt — Brain-Loop in Arbeit" in
    die Card, weil state.gateway noch den alten OpenClaw-Status-Text
    transportiert hat. Brain ist online, sah aber auf der Diagnostic
    aus als waere alles tot.
  - Fix: updateState laesst die Brain-Card jetzt in Ruhe. Daten kommen
    exklusiv von loadBrainStatus() (fetch /api/brain/health).
  - WS-onopen ruft jetzt loadBrainStatus(), plus 15s-Interval fuer
    Live-Refresh.

Runtime-Konfiguration
  - Hinweis-Text erwaehnte noch aria.env + "Bridge-Container neu starten".
    aria.env gibt's nicht mehr, und Brain liest die Config auch.
  - Neu: ".env / Bridge UND Brain lesen sie beim Start — nach Aenderung
    den jeweiligen Container neu starten (Reparatur-Section oben)."

Konversation-Reset
  - Vorher zwei verschiedene Konversations-Stores ohne klare UI:
    chat_backup.jsonl (Diagnostic-Anzeige) und conversation.jsonl
    (Brain Rolling Window). "Konversation leeren" leerte nur Brain,
    die UI zeigte alle Turns weiter → verwirrend.
  - Neu: ein Button "🧹 Konversation komplett zurücksetzen" leert
    beides parallel (Promise.all auf /api/brain/conversation/reset
    + /api/chat-history-clear) plus die lokale Chat-View.
  - Erklaerungstext darunter macht klar was passiert + was bleibt
    (destillierte Facts + Memories in der Vector-DB).
  - Neuer Endpoint /api/chat-history-clear loescht chat_backup.jsonl
    und broadcastet leere chat_history an alle Browser-Clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:59:35 +02:00
duffyduck d12f356ebe fix(diagnostic): JS-Crash beim Laden durch Ghost-IDs aus OpenClaw-Zeit
Die Diagnostic-Seite lud nicht mehr richtig: bei jedem state-Update vom
Server crashte updateState() auf null.className weil 'gw-dot' nicht mehr
existiert (Gateway-Card wurde durch Brain-Card ersetzt). Mehrere weitere
Stellen waren ebenfalls auf nicht mehr existierende Elemente geleitet.

Bereinigt:
  - updateState() nutzt jetzt brain-dot/short/error (null-safe)
  - openCoreTerminal entfernt (aria-core ist raus)
  - closeTermModal null-safe fuer btn-core-term
  - 'core_auth' WS-Event entfernt
  - 'session_restarted' WS-Event entfernt (perms-status-Element war auch raus)
  - 'openclaw_config' WS-Event entfernt
  - rc-compact-after read/write aus loadRuntimeConfig/saveRuntimeConfig raus
    (Compact-After-Messages-Setting wurde mit aria-core entfernt)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:53:30 +02:00
14 changed files with 1279 additions and 172 deletions
+26 -14
View File
@@ -195,12 +195,13 @@ Bestehendes Token nochmal als QR anzeigen: `./generate-token.sh show`
http://<VM-IP>:3001
```
Die Diagnostic-UI hat vier Top-Tabs:
Die Diagnostic-UI hat fünf Top-Tabs:
- **Main** — Live-Chat-Test, Status (Brain / RVS / Proxy), End-to-End-Trace
- **Gehirn** — Memory-Verwaltung (Vector-DB), Skills, Export/Import des kompletten Gehirns als tar.gz
- **Dateien** — alle Dateien aus `/shared/uploads/` (von ARIA generiert oder hochgeladen) mit Download/Delete
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Runtime-Config, App-Onboarding (QR), Komplett-Reset
- **Gehirn** — Memory-Verwaltung (Vector-DB), Token/Call-Metrics (Subscription-Quota), Bootstrap & Migration, Komplett-Gehirn Export/Import
- **Skills** — Liste mit Logs, Run, Activate/Deactivate, Export/Import als tar.gz
- **Dateien** — alle Dateien aus `/shared/uploads/` mit Multi-Select, Bulk-Download (ZIP) + Bulk-Delete
- **Einstellungen** — Reparatur (Container-Restart), Wipe, Sprachausgabe, Whisper, Sprachmodell, Runtime-Config, App-Onboarding (QR), Komplett-Reset
---
@@ -311,13 +312,15 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Tabs
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Gehirn-Export/Import (tar.gz), Skills (geplant)
- **Dateien**: Browser fuer `/shared/uploads/` — von ARIA generierte oder hochgeladene Dateien herunterladen oder loeschen (Live-Update der Chat-Bubbles)
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning, Whisper, Onboarding-QR, App-Cleanup
- **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons () ueberall mit Modal-Erklaerung.
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
- **Einstellungen**: Reparatur (Container-Restart fuer Brain/Bridge/Qdrant), Komplett-Reset, Betriebsmodi, Sprachausgabe + Voice-Cloning + F5-TTS-Tuning + Voice Export/Import, Whisper, Sprachmodell (brainModel), Onboarding-QR, App-Cleanup
### Was zusaetzlich noch drin steckt
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
@@ -842,20 +845,29 @@ docker exec aria-brain curl localhost:8080/memory/stats
### Phase A — Refactor: OpenClaw raus, eigenes Brain rein
- [x] aria-brain Container-Skeleton (FastAPI, Qdrant, sentence-transformers)
- [x] aria-core (OpenClaw) komplett abgerissen — Tag `v0.1.2.0` als Archiv
- [x] Diagnostic: Gehirn-Tab (Memory Search/Filter, Add/Edit/Delete)
- [x] Diagnostic: Gehirn-Export/Import als tar.gz
- [x] Diagnostic: Datei-Manager (Liste, Suche, Download, Delete mit Live-Bubble-Update)
- [x] App: Datei-Manager als Modal in den Einstellungen
- [x] Diagnostic: Datei-Manager (Liste, Suche, Download, Delete, Multi-Select + ZIP + Bulk-Delete)
- [x] Diagnostic: Komplett-Reset (Wipe All)
- [x] Diagnostic: Info-Buttons mit Modal-Erklaerungen (Status, Konversation, Memories, Bootstrap)
- [x] App: Datei-Manager als Modal in den Einstellungen (mit Multi-Select + ZIP-Download)
- [x] Voice Export/Import (einzelne Stimmen + F5/Whisper-Settings als Bundle)
- [x] aria-core (OpenClaw) komplett abgerissen — Tag `v0.1.2.0` als Archiv
- [ ] **Phase B Punkt 2:** Migration `aria-data/brain-import/` → atomare Memory-Punkte
- [ ] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat + Rolling Window + Memory-Destillat)
- [ ] **Phase B Punkt 4:** Skills-System (Manifest, venv, README pro Skill, Diagnostic-Tab)
### Phase B — Brain mit Memory + Loop + Skills
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [x] App-Chat-Sync: verpasste Nachrichten beim Reconnect + chat_cleared Live-Update
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
### Phase 2 — ARIA wird produktiv
- [ ] Skills bauen (Bildgenerierung, etc.)
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, etc. — durch normale Anfragen)
- [ ] Gitea-Integration
- [ ] VM einrichten (Desktop, Browser, Tools)
- [ ] Heartbeat (periodische Selbst-Checks)
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10202
versionName "0.1.2.2"
versionCode 10203
versionName "0.1.2.3"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.2.2",
"version": "0.1.2.3",
"private": true,
"scripts": {
"android": "react-native run-android",
+135 -5
View File
@@ -201,6 +201,7 @@ const ChatScreen: React.FC = () => {
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchVisible, setSearchVisible] = useState(false);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
@@ -396,6 +397,52 @@ const ChatScreen: React.FC = () => {
}
// skill_created: ARIA hat einen neuen Skill angelegt → eigene Bubble
// chat_cleared: Diagnostic hat die History komplett geleert
// → lokal auch loeschen (visuell + Persistenz)
if (message.type === 'chat_cleared') {
console.log('[Chat] chat_cleared — leere lokale Anzeige + Storage');
setMessages([]);
AsyncStorage.removeItem(CHAT_STORAGE_KEY).catch(() => {});
AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {});
return;
}
// chat_history_response: verpasste Nachrichten nachladen (bei Reconnect)
if (message.type === 'chat_history_response') {
const p = (message.payload || {}) as any;
const incoming = (p.messages || []) as Array<any>;
if (!incoming.length) return;
console.log(`[Chat] ${incoming.length} verpasste Nachrichten nachgeladen`);
const toAdd: ChatMessage[] = incoming.map(m => {
const role = m.role === 'user' ? 'user' : 'aria';
// ARIA-File-Marker aus dem Backup als attachments rekonstruieren
const files = Array.isArray(m.files) ? m.files : [];
const attachments = files.map((f: any) => ({
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
name: f.name || 'datei',
size: f.size || 0,
mimeType: f.mimeType || '',
serverPath: f.serverPath || '',
})) as Attachment[];
return {
id: nextId(),
sender: role as 'user' | 'aria',
text: m.text || '',
timestamp: m.ts || Date.now(),
attachments: attachments.length ? attachments : undefined,
};
});
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
setMessages(prev => {
// Dedup auf ts-basis: nicht erneut adden wenn schon was bei +/- 1s vorhanden
const existingTs = new Set(prev.map(m => m.timestamp));
const newOnes = toAdd.filter(m => !existingTs.has(m.timestamp));
return capMessages([...prev, ...newOnes]);
});
if (maxTs > 0) AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {});
return;
}
if (message.type === 'skill_created') {
const p = (message.payload || {}) as any;
const skillMsg: ChatMessage = {
@@ -480,6 +527,13 @@ const ChatScreen: React.FC = () => {
const dbgText = ((message.payload.text as string) || '').slice(0, 60);
console.log('[Chat] chat-event sender=%s text=%s', sender || '(none)', dbgText);
// last-sync tracken — so dass beim Reconnect nicht wieder dieselbe
// Nachricht aus dem Server-Backup nachgeladen wird
if (sender === 'aria' || sender === 'user' || sender === 'stt') {
const ts = message.timestamp || Date.now();
AsyncStorage.setItem('aria_chat_last_sync', String(ts)).catch(() => {});
}
// STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben.
// WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
// wuerde bei zwei kurz hintereinander gesendeten Audios beide Bubbles
@@ -647,6 +701,15 @@ const ChatScreen: React.FC = () => {
const unsubState = rvs.onStateChange((state) => {
setConnectionState(state);
// Bei (re)connect: verpasste Chat-Eintraege seit der letzten gesehenen
// Nachricht abholen. lastChatSync wird beim Eingang von Nachrichten
// hochgezaehlt; default 0 = alle (gecappt auf Server-Limit).
if (state === 'connected') {
AsyncStorage.getItem('aria_chat_last_sync').then(stored => {
const since = stored ? parseInt(stored, 10) || 0 : 0;
rvs.send('chat_history_request' as any, { since, limit: 100 });
}).catch(() => {});
}
});
// Initalen Status setzen
@@ -830,6 +893,43 @@ const ChatScreen: React.FC = () => {
// Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);
// Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
// Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
const searchMatchIds = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return [] as string[];
return messages
.filter(m => (m.text || '').toLowerCase().includes(q))
.map(m => m.id);
}, [messages, searchQuery]);
useEffect(() => {
setSearchIndex(0);
}, [searchQuery]);
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen
useEffect(() => {
if (!searchMatchIds.length) return;
const id = searchMatchIds[searchIndex];
if (!id) return;
// invertedMessages → index in der angezeigten Liste finden
const idx = invertedMessages.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return;
try {
flatListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.4 });
} catch {}
}, [searchIndex, searchMatchIds, invertedMessages]);
const activeSearchId = searchMatchIds[searchIndex] || '';
const gotoSearchPrev = () => {
if (!searchMatchIds.length) return;
setSearchIndex(i => (i - 1 + searchMatchIds.length) % searchMatchIds.length);
};
const gotoSearchNext = () => {
if (!searchMatchIds.length) return;
setSearchIndex(i => (i + 1) % searchMatchIds.length);
};
// GPS-Position holen (optional)
const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
if (!gpsEnabled) {
@@ -1081,12 +1181,16 @@ const ChatScreen: React.FC = () => {
hour: '2-digit',
minute: '2-digit',
});
const isSearchHit = activeSearchId === item.id;
const searchHighlightStyle = isSearchHit
? { borderWidth: 2, borderColor: '#FFD60A' }
: null;
// Spezial-Bubble: ARIA hat einen Skill erstellt
if (item.skillCreated) {
const s = item.skillCreated;
return (
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}]}>
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
{'🛠 ARIA hat einen neuen Skill erstellt'}
</Text>
@@ -1106,7 +1210,7 @@ const ChatScreen: React.FC = () => {
}
return (
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble]}>
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
{/* Anhang-Vorschau */}
{item.attachments?.map((att, idx) => (
<View key={idx}>
@@ -1280,7 +1384,7 @@ const ChatScreen: React.FC = () => {
);
})()}
{/* Suchleiste */}
{/* Suchleiste mit Treffer-Navigation */}
{searchVisible && (
<View style={styles.searchBar}>
<TextInput
@@ -1291,17 +1395,43 @@ const ChatScreen: React.FC = () => {
placeholderTextColor="#555570"
autoFocus
/>
{searchQuery ? (
<Text style={{color: searchMatchIds.length ? '#0096FF' : '#555570', fontSize: 12, paddingHorizontal: 6}}>
{searchMatchIds.length ? `${searchIndex + 1}/${searchMatchIds.length}` : '0/0'}
</Text>
) : null}
<TouchableOpacity
onPress={gotoSearchPrev}
disabled={!searchMatchIds.length}
style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
>
<Text style={{color: '#0096FF', fontSize: 18}}>{'▲'}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={gotoSearchNext}
disabled={!searchMatchIds.length}
style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
>
<Text style={{color: '#0096FF', fontSize: 18}}>{'▼'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => { setSearchVisible(false); setSearchQuery(''); }}>
<Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>X</Text>
</TouchableOpacity>
</View>
)}
{/* Nachrichtenliste */}
{/* Nachrichtenliste — Suche FILTERT NICHT mehr, sondern hebt aktiven
Treffer hervor (siehe renderMessage: activeSearchId-Border). */}
<FlatList
ref={flatListRef}
inverted
data={searchQuery ? messages.filter(m => m.text.toLowerCase().includes(searchQuery.toLowerCase())).reverse() : invertedMessages}
data={invertedMessages}
onScrollToIndexFailed={(info) => {
// Bei zu schnellem Aufruf vor Layout: einmal nachfassen
setTimeout(() => {
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.4 }); } catch {}
}, 200);
}}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
+193 -54
View File
@@ -155,6 +155,9 @@ const SettingsScreen: React.FC = () => {
const [fileManagerError, setFileManagerError] = useState('');
const [fileManagerSearch, setFileManagerSearch] = useState('');
const [fileManagerFilter, setFileManagerFilter] = useState<'all' | 'aria' | 'user'>('all');
const [fileManagerSelected, setFileManagerSelected] = useState<Set<string>>(new Set());
const fileZipPending = useRef<string | null>(null); // requestId fuer ZIP-Antwort
const [fileZipBusy, setFileZipBusy] = useState(false);
const [voiceCloneVisible, setVoiceCloneVisible] = useState(false);
const [tempPath, setTempPath] = useState('');
// Sub-Screen Navigation: null = Hauptmenue, sonst eine der Section-IDs.
@@ -395,9 +398,39 @@ const SettingsScreen: React.FC = () => {
const p: any = message.payload || {};
if (p.path) {
setFileManagerFiles(prev => prev.filter(f => f.path !== p.path));
setFileManagerSelected(prev => {
if (!prev.has(p.path)) return prev;
const next = new Set(prev);
next.delete(p.path);
return next;
});
}
}
// Datei-Manager: ZIP-Response (Multi-Download)
if (message.type === ('file_zip_response' as any)) {
const p: any = message.payload || {};
if (p.requestId && p.requestId !== fileZipPending.current) return; // veraltet
fileZipPending.current = null;
setFileZipBusy(false);
if (!p.ok || !p.data) {
ToastAndroid.show('ZIP fehlgeschlagen: ' + (p.error || 'unbekannt'), ToastAndroid.LONG);
return;
}
// base64 → in Downloads-Ordner schreiben
(async () => {
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const dir = RNFS.DownloadDirectoryPath;
const filePath = `${dir}/aria-files-${ts}.zip`;
await RNFS.writeFile(filePath, p.data, 'base64');
ToastAndroid.show(`ZIP gespeichert: ${filePath} (${Math.round((p.size||0)/1024)} KB)`, ToastAndroid.LONG);
} catch (e: any) {
ToastAndroid.show('ZIP speichern fehlgeschlagen: ' + e.message, ToastAndroid.LONG);
}
})();
}
// Voice wurde gespeichert → Liste neu laden + ggf. auswaehlen
if (message.type === ('xtts_voice_saved' as any)) {
const name = (message.payload as any).name as string;
@@ -644,64 +677,170 @@ const SettingsScreen: React.FC = () => {
<Text style={{color:'#8888AA', textAlign:'center', marginTop:20}}>Lade...</Text>
) : fileManagerError ? (
<Text style={{color:'#FF6B6B', textAlign:'center', marginTop:20}}>{fileManagerError}</Text>
) : (
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12}}>
{(() => {
let files = fileManagerFiles;
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
if (fileManagerSearch) {
const q = fileManagerSearch.toLowerCase();
files = files.filter(f => f.name.toLowerCase().includes(q));
}
if (!files.length) {
return <Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>;
}
const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`;
return files.map(f => (
<View key={f.path} style={{
backgroundColor:'#0D0D1A', padding:12, borderRadius:8, marginBottom:8,
flexDirection:'row', alignItems:'center', gap:8,
}}>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center'}}>
) : (() => {
// Visible files (Filter+Suche)
let files = fileManagerFiles;
if (fileManagerFilter === 'aria') files = files.filter(f => f.fromAria);
else if (fileManagerFilter === 'user') files = files.filter(f => !f.fromAria);
if (fileManagerSearch) {
const q = fileManagerSearch.toLowerCase();
files = files.filter(f => f.name.toLowerCase().includes(q));
}
const visiblePaths = files.map(f => f.path);
const selectedHere = visiblePaths.filter(p => fileManagerSelected.has(p));
const allSelected = visiblePaths.length > 0 && selectedHere.length === visiblePaths.length;
const fmtSize = (b: number) => b < 1024 ? `${b} B` : b < 1024*1024 ? `${(b/1024).toFixed(1)} KB` : `${(b/1024/1024).toFixed(1)} MB`;
const toggleSelectAll = () => {
setFileManagerSelected(prev => {
const next = new Set(prev);
if (allSelected) visiblePaths.forEach(p => next.delete(p));
else visiblePaths.forEach(p => next.add(p));
return next;
});
};
const toggleOne = (p: string) => {
setFileManagerSelected(prev => {
const next = new Set(prev);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
};
const bulkDelete = () => {
const paths = [...fileManagerSelected];
if (!paths.length) return;
Alert.alert(
`${paths.length} Dateien löschen?`,
'In allen Chat-Bubbles werden sie als gelöscht markiert.',
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => {
rvs.send('file_delete_batch_request' as any, { paths, requestId: 'batch-' + Date.now() });
setFileManagerSelected(new Set());
ToastAndroid.show(`${paths.length} Lösch-Befehle gesendet…`, ToastAndroid.SHORT);
}},
],
);
};
const bulkDownload = () => {
const paths = [...fileManagerSelected];
if (!paths.length) return;
// 1 Datei: einfach via file_request (existing pattern). ZIP nur bei 2+.
if (paths.length === 1) {
rvs.send('file_request' as any, { serverPath: paths[0], requestId: 'single-' + Date.now() });
ToastAndroid.show('Datei wird heruntergeladen…', ToastAndroid.SHORT);
return;
}
const reqId = 'zip-' + Date.now();
fileZipPending.current = reqId;
setFileZipBusy(true);
rvs.send('file_zip_request' as any, { paths, requestId: reqId });
ToastAndroid.show(`ZIP wird erstellt (${paths.length} Dateien)…`, ToastAndroid.LONG);
};
return (
<>
{/* Bulk-Bar */}
<View style={{paddingHorizontal:12, paddingBottom:8, flexDirection:'row', alignItems:'center', gap:8, flexWrap:'wrap'}}>
<TouchableOpacity onPress={toggleSelectAll} style={{flexDirection:'row', alignItems:'center', gap:6, paddingVertical:4}}>
<View style={{
width:18, height:18, borderRadius:3,
borderWidth:2, borderColor: allSelected ? '#0096FF' : '#555570',
backgroundColor: allSelected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}>
{allSelected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}></Text>}
</View>
<Text style={{color:'#E0E0F0', fontSize:13}}>Alle markieren</Text>
</TouchableOpacity>
{fileManagerSelected.size > 0 && (
<>
<Text style={{color:'#555570', fontSize:13}}>·</Text>
<Text style={{color:'#0096FF', fontSize:13, fontWeight:'600'}}>{fileManagerSelected.size} ausgewählt</Text>
<TouchableOpacity
onPress={bulkDownload}
disabled={fileZipBusy}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#0096FF22', opacity: fileZipBusy ? 0.5 : 1}}
>
<Text style={{color:'#0096FF', fontSize:12}}>{fileZipBusy ? '⏳ ZIP…' : (fileManagerSelected.size > 1 ? '⬇ ZIP' : '⬇ Download')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={bulkDelete}
style={{paddingVertical:4, paddingHorizontal:10, borderRadius:6, backgroundColor:'#FF6B6B22'}}
>
<Text style={{color:'#FF6B6B', fontSize:12}}>🗑 Löschen</Text>
</TouchableOpacity>
</>
)}
</View>
<ScrollView style={{flex:1}} contentContainerStyle={{padding:12, paddingTop:0}}>
{!files.length ? (
<Text style={{color:'#555570', textAlign:'center', marginTop:20}}>Keine Dateien</Text>
) : files.map(f => {
const selected = fileManagerSelected.has(f.path);
return (
<TouchableOpacity
key={f.path}
onPress={() => toggleOne(f.path)}
activeOpacity={0.7}
style={{
backgroundColor: selected ? '#1E2C44' : '#0D0D1A',
padding:12, borderRadius:8, marginBottom:8,
flexDirection:'row', alignItems:'center', gap:8,
borderWidth: selected ? 1 : 0, borderColor:'#0096FF',
}}
>
<View style={{
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
width:18, height:18, borderRadius:3,
borderWidth:2, borderColor: selected ? '#0096FF' : '#555570',
backgroundColor: selected ? '#0096FF' : 'transparent',
alignItems:'center', justifyContent:'center',
}}>
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
{f.fromAria ? 'ARIA' : 'USER'}
{selected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}></Text>}
</View>
<View style={{flex:1}}>
<View style={{flexDirection:'row', alignItems:'center'}}>
<View style={{
backgroundColor: f.fromAria ? '#0096FF22' : '#34C75922',
paddingHorizontal:6, paddingVertical:1, borderRadius:3, marginRight:6,
}}>
<Text style={{color: f.fromAria ? '#0096FF' : '#34C759', fontSize:9}}>
{f.fromAria ? 'ARIA' : 'USER'}
</Text>
</View>
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text>
</View>
<Text style={{color:'#E0E0F0', fontSize:13, flex:1}} numberOfLines={1}>{f.name}</Text>
</View>
<Text style={{color:'#555570', fontSize:10, marginTop:2}}>
{fmtSize(f.size)} · {new Date(f.mtime).toLocaleString('de-DE')}
</Text>
</View>
<TouchableOpacity
onPress={() => {
Alert.alert(
'Datei löschen?',
`"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => {
rvs.send('file_delete_request' as any, { path: f.path });
ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT);
}},
],
);
}}
style={{padding:8}}
>
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
</TouchableOpacity>
</View>
));
})()}
</ScrollView>
)}
<TouchableOpacity
onPress={() => {
Alert.alert(
'Datei löschen?',
`"${f.name}"\n\nIn allen Chat-Bubbles wird sie als gelöscht markiert.`,
[
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Löschen', style: 'destructive', onPress: () => {
rvs.send('file_delete_request' as any, { path: f.path });
ToastAndroid.show('Lösch-Befehl gesendet…', ToastAndroid.SHORT);
}},
],
);
}}
style={{padding:8}}
>
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
</TouchableOpacity>
</TouchableOpacity>
);
})}
</ScrollView>
</>
);
})()}
</View>
</Modal>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
+10
View File
@@ -29,6 +29,7 @@ from conversation import Conversation
from proxy_client import ProxyClient
from agent import Agent
import skills as skills_mod
import metrics as metrics_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -404,6 +405,15 @@ def conversation_distill_now():
return agent().distill_old_turns()
# ─── Call-Metrics (Token / Quota-Monitoring) ────────────────────────
@app.get("/metrics/calls")
def metrics_calls():
"""Liefert Aggregate fuer 1h / 5h / 24h / 30d.
Jedes Window: {window_seconds, calls, tokens_in, tokens_out, by_model}."""
return metrics_mod.stats()
# ─── Skills ─────────────────────────────────────────────────────────
class SkillCreate(BaseModel):
+133
View File
@@ -0,0 +1,133 @@
"""
Call-Metrics fuer den Proxy-Client.
Pro Claude-Call wird ein Eintrag in /data/metrics.jsonl angehaengt:
{"ts": <ms>, "model": "...", "in": <tokens_in_estimate>, "out": <tokens_out_estimate>}
Tokens-Schaetzung: characters / 4 (Anthropic-Default-Heuristik). Nicht exakt
aber gut genug fuer Quota-Monitoring. Wir summieren nicht in-memory weil
der Brain-Container neugestartet werden kann — alles auf Disk.
Auswertung via aggregate(window_seconds) — liefert {calls, tokens_in, tokens_out}
fuer die letzten N Sekunden. Lazy gelesen, keine grossen Datenmengen erwartet
(bei 1000 Calls/Tag ~70 KB pro Monat).
Auto-Rotate: bei > 50k Zeilen werden die aeltesten 25k weggeschnitten.
"""
from __future__ import annotations
import json
import logging
import os
import time
from pathlib import Path
from typing import List
logger = logging.getLogger(__name__)
METRICS_FILE = Path(os.environ.get("METRICS_FILE", "/data/metrics.jsonl"))
ROTATE_AT = 50_000
ROTATE_KEEP = 25_000
def _estimate_tokens(text: str) -> int:
"""Anthropic-Default: ~4 chars pro Token. Grob genug."""
if not text:
return 0
return max(1, len(text) // 4)
def _messages_tokens(messages: list) -> int:
total = 0
for m in messages:
# Pydantic-Model oder dict
if hasattr(m, "content"):
total += _estimate_tokens(m.content or "")
elif isinstance(m, dict):
c = m.get("content") or ""
if isinstance(c, str):
total += _estimate_tokens(c)
return total
def log_call(model: str, messages_in: list, reply_text: str = "") -> None:
"""Eine Call-Metric anhaengen. Robust gegen Fehler (silent fail)."""
try:
tokens_in = _messages_tokens(messages_in)
tokens_out = _estimate_tokens(reply_text)
line = json.dumps({
"ts": int(time.time() * 1000),
"model": model,
"in": tokens_in,
"out": tokens_out,
})
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
# Sanftes Rotate ohne hohe IO-Kosten — nur alle 1000 Calls checken
if (tokens_in + tokens_out) % 1000 < 4:
_maybe_rotate()
except Exception as exc:
logger.warning("metrics.log_call: %s", exc)
def _maybe_rotate() -> None:
try:
if not METRICS_FILE.exists():
return
with METRICS_FILE.open("r", encoding="utf-8") as f:
lines = f.readlines()
if len(lines) > ROTATE_AT:
keep = lines[-ROTATE_KEEP:]
METRICS_FILE.write_text("".join(keep), encoding="utf-8")
logger.info("metrics rotated: %d%d Zeilen", len(lines), len(keep))
except Exception as exc:
logger.warning("metrics rotate: %s", exc)
def aggregate(window_seconds: int) -> dict:
"""Aggregiert die Calls der letzten N Sekunden."""
now_ms = int(time.time() * 1000)
cutoff_ms = now_ms - (window_seconds * 1000)
calls = 0
tokens_in = 0
tokens_out = 0
by_model: dict[str, int] = {}
if METRICS_FILE.exists():
try:
for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines():
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if obj.get("ts", 0) < cutoff_ms:
continue
calls += 1
tokens_in += int(obj.get("in") or 0)
tokens_out += int(obj.get("out") or 0)
m = obj.get("model", "?")
by_model[m] = by_model.get(m, 0) + 1
except Exception as exc:
logger.warning("metrics aggregate: %s", exc)
return {
"window_seconds": window_seconds,
"calls": calls,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"by_model": by_model,
}
def stats() -> dict:
"""Komplett-Snapshot mit den drei wichtigsten Fenstern."""
return {
"h1": aggregate(3600),
"h5": aggregate(5 * 3600),
"h24": aggregate(24 * 3600),
"d30": aggregate(30 * 24 * 3600),
}
+5
View File
@@ -18,6 +18,8 @@ from typing import List, Optional
import httpx
from pydantic import BaseModel
import metrics
logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
@@ -135,6 +137,9 @@ class ProxyClient:
"arguments": args,
})
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
metrics.log_call(payload["model"], messages, content or "")
return ProxyResult(content=content or "", tool_calls=tool_calls, finish_reason=finish_reason)
def close(self):
+163
View File
@@ -919,6 +919,56 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
def _append_chat_backup(self, entry: dict) -> None:
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
Wird von Diagnostic + App als History-Quelle gelesen.
entry braucht mindestens {role, text}; ts wird ergaenzt."""
try:
line = {"ts": int(asyncio.get_event_loop().time() * 1000)}
line.update(entry)
Path("/shared/config").mkdir(parents=True, exist_ok=True)
with open("/shared/config/chat_backup.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("[backup] chat_backup-Write fehlgeschlagen: %s", e)
def _read_chat_backup_since(self, since_ms: int, limit: int = 100) -> list[dict]:
"""Liest chat_backup.jsonl, gibt Eintraege > since_ms zurueck, max limit neueste.
File-deleted-Marker werden honoriert: vor einem file_deleted-Marker liegende
Eintraege mit gleichem Pfad werden als deleted markiert."""
path = Path("/shared/config/chat_backup.jsonl")
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except Exception as e:
logger.warning("[backup] Lesen fehlgeschlagen: %s", e)
return []
out: list[dict] = []
for raw in lines:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
ts = obj.get("ts") or 0
if ts <= since_ms:
continue
# file_deleted-Marker: nicht als Chat ausliefern, aber an die App schicken
# damit sie ihre Bubbles updaten kann (separater Pfad existiert ja schon)
if obj.get("type") == "file_deleted":
continue
role = obj.get("role")
if role not in ("user", "assistant"):
continue
out.append(obj)
# Auf "limit" neueste cappen
if len(out) > limit:
out = out[-limit:]
return out
async def _process_core_response(self, text: str, payload: dict) -> None:
"""Verarbeitet eine fertige Antwort von aria-core.
@@ -933,6 +983,9 @@ class ARIABridge:
logger.info("[core] NO_REPLY empfangen — Antwort still verworfen")
return
# Antwort in chat_backup.jsonl loggen (cleaned text, ohne File-Marker)
# — passiert weiter unten nach extract_file_markers
# File-Marker `[FILE: /shared/uploads/aria_xyz.pdf]` extrahieren —
# ARIA legt damit Dateien fuer den User bereit (Bilder, PDFs, etc.).
# Der Marker wird aus dem Antworttext entfernt (TTS soll ihn nicht
@@ -949,6 +1002,15 @@ class ARIABridge:
f"aber nicht erstellt:\n{missing_list}\n"
"Bitte ARIA bitten, sie wirklich zu schreiben.").strip()
# Antwort in chat_backup.jsonl loggen (gecleanter Text, ohne File-Marker)
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
self._append_chat_backup({
"role": "assistant",
"text": text,
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
})
metadata = payload.get("metadata", {})
is_critical = metadata.get("critical", False)
requested_voice = metadata.get("voice")
@@ -1184,6 +1246,10 @@ class ARIABridge:
payload = json.dumps({"message": text, "source": source}).encode("utf-8")
logger.info("[brain] chat ← %s '%s'", source, text[:80])
# User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect
# / Diagnostic-Reload als History-Quelle gelesen.
self._append_chat_backup({"role": "user", "text": text, "source": source})
# agent_activity broadcasten (App + Diagnostic "ARIA denkt..." Indicator)
await self._send_to_rvs({
"type": "agent_activity",
@@ -1657,6 +1723,20 @@ class ARIABridge:
except Exception as e:
logger.warning("[rvs] file_saved konnte nicht an App gesendet werden: %s", e)
elif msg_type == "chat_history_request":
# App holt verpasste Nachrichten beim Reconnect.
# payload: {since: <ts_ms>}, default 0 = alles
since = int(payload.get("since") or 0)
limit = int(payload.get("limit") or 100)
logger.info("[rvs] chat_history_request since=%d limit=%d", since, limit)
messages = self._read_chat_backup_since(since, limit=limit)
await self._send_to_rvs({
"type": "chat_history_response",
"payload": {"messages": messages, "since": since},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
elif msg_type == "file_list_request":
# App fragt die Liste aller /shared/uploads/-Dateien an.
logger.info("[rvs] file_list_request von App")
@@ -1681,6 +1761,89 @@ class ARIABridge:
logger.warning("[rvs] file_list_request: %s", e)
return
elif msg_type == "file_delete_batch_request":
# App will mehrere Dateien auf einmal loeschen.
paths = payload.get("paths") or []
req_id = payload.get("requestId", "")
logger.warning("[rvs] file_delete_batch_request: %d Pfade", len(paths))
try:
body_bytes = json.dumps({"paths": paths}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:3001/api/files-delete-batch",
data=body_bytes, method="POST",
headers={"Content-Type": "application/json"},
)
def _do_delete():
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read().decode("utf-8", errors="ignore")
except Exception as e:
return None, str(e)
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_delete)
logger.info("[rvs] file_delete_batch result: status=%s", status)
# Server broadcastet file_deleted pro Pfad — App kriegt das via persistente RVS.
# Wir bestaetigen zusaetzlich mit Counts.
try: d = json.loads(body or "{}")
except: d = {}
await self._send_to_rvs({
"type": "file_delete_batch_response",
"payload": {
"requestId": req_id,
"deleted": len(d.get("deleted", [])),
"errors": d.get("errors", []),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception as e:
logger.warning("[rvs] file_delete_batch_request: %s", e)
return
elif msg_type == "file_zip_request":
# App will mehrere Dateien als ZIP. Bridge holt ZIP von Diagnostic
# via HTTP, kodiert base64 und schickt zurueck. Cap auf 30 MB
# ZIP-Groesse damit RVS nicht erstickt.
paths = payload.get("paths") or []
req_id = payload.get("requestId", "")
logger.warning("[rvs] file_zip_request: %d Pfade (req=%s)", len(paths), req_id)
def _do_zip():
try:
body_bytes = json.dumps({"paths": paths}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:3001/api/files-download-zip",
data=body_bytes, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
if resp.status != 200:
return None, f"HTTP {resp.status}"
data = resp.read()
if len(data) > 30 * 1024 * 1024:
return None, f"ZIP zu gross ({len(data) // (1024*1024)} MB > 30 MB)"
return data, None
except Exception as e:
return None, str(e)
data, err = await asyncio.get_event_loop().run_in_executor(None, _do_zip)
if err or data is None:
await self._send_to_rvs({
"type": "file_zip_response",
"payload": {"requestId": req_id, "ok": False, "error": err or "leer"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
import base64 as _b64
await self._send_to_rvs({
"type": "file_zip_response",
"payload": {
"requestId": req_id, "ok": True,
"size": len(data),
"data": _b64.b64encode(data).decode("ascii"),
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
elif msg_type == "file_delete_request":
# App will eine Datei loeschen — leite an Diagnostic.
p = payload.get("path", "")
+2
View File
@@ -1,5 +1,7 @@
FROM node:22-alpine
WORKDIR /app
# zip fuer Multi-Datei-Downloads (Brain-Export nutzt tar.gz, Datei-Manager zip)
RUN apk add --no-cache zip
COPY package.json ./
RUN npm install --production
COPY . .
+461 -83
View File
@@ -120,6 +120,24 @@
/* Settings */
.settings-section { margin-bottom:20px; }
.settings-section h2 { margin-bottom:12px; }
/* Metric-Zellen im Token/Calls-Card */
.metric-cell { background:#0D0D1A; border:1px solid #1E1E2E; border-radius:6px; padding:8px 10px; }
.metric-cell .metric-label { color:#8888AA; font-size:10px; }
.metric-cell .metric-value { color:#E0E0F0; font-size:18px; font-weight:bold; margin-top:2px; }
.metric-cell .metric-sub { color:#555570; font-size:10px; margin-top:2px; font-family:monospace; }
.metric-cell.warn { border-color:#FFD60A; background:rgba(255,214,10,0.08); }
.metric-cell.crit { border-color:#FF6B6B; background:rgba(255,107,107,0.10); }
/* Info-Button: kleines (i) neben Ueberschriften */
.info-btn { background:transparent; border:1px solid #0096FF; color:#0096FF; width:20px; height:20px;
border-radius:50%; padding:0; font-size:11px; font-weight:bold; cursor:pointer; margin-left:6px;
line-height:18px; text-align:center; vertical-align:middle; font-family:serif; }
.info-btn:hover { background:#0096FF; color:#fff; }
.info-btn-small { background:transparent; border:1px solid #0096FF44; color:#0096FF; width:14px; height:14px;
border-radius:50%; padding:0; font-size:9px; font-weight:bold; cursor:pointer; margin-left:4px;
line-height:11px; text-align:center; vertical-align:middle; font-family:serif; }
.info-btn-small:hover { background:#0096FF; color:#fff; }
.toggle { position:relative; width:40px; height:22px; flex-shrink:0; margin-left:8px; }
.toggle input { opacity:0; width:0; height:0; }
.toggle .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0;
@@ -460,14 +478,9 @@
<!-- Stimmen -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h2 style="margin:0;">Sprachausgabe</h2>
<div style="display:flex;gap:6px;">
<button class="btn secondary" onclick="exportVoiceSettings()" style="padding:4px 10px;font-size:11px;" title="voice_config.json + highlight_triggers herunterladen">⬇ Export</button>
<input type="file" id="voice-settings-import-file" accept=".json,application/json" style="display:none" onchange="importVoiceSettings(event)">
<button class="btn secondary" onclick="document.getElementById('voice-settings-import-file').click()" style="padding:4px 10px;font-size:11px;">⬆ Import</button>
</div>
</div>
<h2>Sprachausgabe</h2>
<!-- file-input fuer Import (versteckt, wird vom Button im Details-Block getriggert) -->
<input type="file" id="voice-settings-import-file" accept=".json,application/json" style="display:none" onchange="importVoiceSettings(event)">
<div class="card" style="max-width:500px;">
<!-- TTS aktiv (global) -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
@@ -536,9 +549,17 @@
</div>
</div>
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;align-self:flex-start;margin-top:6px;">
Anwenden
</button>
<div style="display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap;">
<button class="btn primary" onclick="sendVoiceConfig()" style="padding:6px 14px;font-size:12px;">
Anwenden
</button>
<button class="btn secondary" onclick="exportVoiceSettings()" style="padding:6px 14px;font-size:12px;" title="voice_config.json + highlight_triggers als JSON-Bundle herunterladen">
⬇ Export
</button>
<button class="btn secondary" onclick="document.getElementById('voice-settings-import-file').click()" style="padding:6px 14px;font-size:12px;" title="JSON-Bundle einspielen">
⬆ Import
</button>
</div>
</div>
</details>
@@ -592,14 +613,14 @@
</div>
</div>
<!-- Runtime-Konfiguration (migriert von .env) -->
<!-- Runtime-Konfiguration -->
<div class="settings-section">
<h2>Runtime-Konfiguration</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Werte werden in <code>/shared/config/runtime.json</code> persistiert und
ueberschreiben die ENV-Variablen aus <code>aria.env</code>. Bridge liest
sie beim naechsten Start — nach Aenderung <b>Bridge-Container neu starten</b>
(Diagnostic-Container bleibt auf ENV).
ueberschreiben die ENV-Variablen aus der <code>.env</code>. Bridge und Brain
lesen sie beim Start — nach Aenderung den jeweiligen Container neu starten
(Reparatur-Section oben).
</div>
<div class="card" style="max-width:600px;">
<div style="display:grid;grid-template-columns:140px 1fr;gap:8px 10px;align-items:center;font-size:13px;">
@@ -689,20 +710,53 @@
<div id="tab-brain" class="main-tab">
<div class="settings-section">
<h2>Gehirn — Status</h2>
<h2>Gehirn — Status <button class="info-btn" onclick="showInfo('brain-status')" title="Was bedeutet was?"></button></h2>
<div class="card">
<div id="brain-status" style="font-size:12px;color:#8888AA;margin-bottom:8px;">(Lade...)</div>
<div id="conversation-status" style="font-size:12px;color:#8888AA;margin-bottom:8px;"></div>
<div id="conversation-status" style="font-size:12px;color:#8888AA;margin-bottom:8px;">
<button class="info-btn-small" onclick="showInfo('conversation')" title="Konversation — wie funktioniert das?"></button>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="btn secondary" onclick="loadBrainStatus()" style="padding:4px 12px;font-size:11px;">Aktualisieren</button>
<button class="btn secondary" onclick="distillNow()" style="padding:4px 12px;font-size:11px;color:#FFD60A;border-color:#FFD60A;" title="Destilliert die aeltesten Turns sofort zu fact-Memories">⚗ Jetzt destillieren</button>
<button class="btn secondary" onclick="resetConversation()" style="padding:4px 12px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;" title="Rolling-Window leeren — destillierte Facts bleiben in der DB">🧹 Konversation leeren</button>
<button class="btn secondary" onclick="resetConversation()" style="padding:4px 12px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;" title="Leert ARIAs Rolling-Window (Brain) + die Chat-Anzeige (chat_backup). Memories bleiben in der Vector-DB.">🧹 Konversation komplett zurücksetzen</button>
</div>
</div>
</div>
<div class="settings-section">
<h2>Bootstrap & Migration</h2>
<h2>Token / Calls <button class="info-btn" onclick="showInfo('metrics')" title="Wie zaehlen die Calls? Was sind die Subscription-Limits?"></button></h2>
<div class="card">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px;">
<label style="color:#8888AA;font-size:12px;">Anthropic-Plan:</label>
<select id="metrics-plan" onchange="onMetricsPlanChange()" style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:6px;font-family:inherit;font-size:12px;">
<option value="pro">Pro (~45 / 5h)</option>
<option value="max5" selected>Max 5x (~225 / 5h)</option>
<option value="max20">Max 20x (~900 / 5h)</option>
<option value="custom">Custom...</option>
</select>
<span id="metrics-custom-row" style="display:none;">
<label style="color:#8888AA;font-size:12px;">Custom 5h-Limit:</label>
<input type="number" id="metrics-custom-limit" min="10" max="5000" step="10" style="width:80px;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px;">
</span>
<button class="btn secondary" onclick="loadMetrics()" style="padding:4px 10px;font-size:11px;margin-left:auto;">Aktualisieren</button>
</div>
<div id="metrics-bar" style="margin-bottom:10px;"></div>
<div id="metrics-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;font-size:12px;">
<div class="metric-cell" id="metrics-h1"><div class="metric-label">letzte 1h</div><div class="metric-value"></div></div>
<div class="metric-cell" id="metrics-h5"><div class="metric-label">letzte 5h (Quota-Fenster)</div><div class="metric-value"></div></div>
<div class="metric-cell" id="metrics-h24"><div class="metric-label">letzte 24h</div><div class="metric-value"></div></div>
<div class="metric-cell" id="metrics-d30"><div class="metric-label">letzte 30 Tage</div><div class="metric-value"></div></div>
</div>
<div style="margin-top:8px;font-size:10px;color:#555570;line-height:1.5;">
Pro User-Frage = mind. 1 Claude-Call. Bei Tool-Use (Skills) bis zu 8 Calls. Plus 1 Destillat-Call bei langen Konversationen.
Token-Werte sind Schaetzung (chars/4) — nicht exakt, aber gut genug fuer Quota-Monitoring.
</div>
</div>
</div>
<div class="settings-section">
<h2>Bootstrap & Migration <button class="info-btn" onclick="showInfo('bootstrap')" title="Was sind die drei Wege?"></button></h2>
<div class="card" style="line-height:1.6;">
<p style="color:#8888AA;font-size:12px;margin:0 0 12px;">
Drei Wege ARIA mit "Grundregeln" zu füttern — von leichtgewichtig bis Voll-Wiederherstellung.
@@ -754,7 +808,7 @@
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">Memories</h2>
<h2 style="margin:0;">Memories <button class="info-btn" onclick="showInfo('memories')" title="Hot vs. Cold — wie funktioniert das Gedaechtnis?"></button></h2>
<div>
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
<button class="btn" onclick="openMemoryModal()" style="padding:4px 10px;font-size:11px;">+ Neu</button>
@@ -845,6 +899,17 @@
</div>
</div><!-- /tab-skills -->
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
<div class="modal-overlay" id="info-modal">
<div class="modal-box" style="max-width:640px;">
<div class="modal-header">
<h3 id="info-modal-title">Info</h3>
<button class="modal-close" onclick="closeInfoModal()">&times;</button>
</div>
<div class="modal-body" id="info-modal-body" style="padding:16px;font-size:13px;color:#E0E0F0;line-height:1.6;"></div>
</div>
</div>
<!-- Memory-Modal (Neu + Editieren) -->
<div class="modal-overlay" id="memory-modal">
<div class="modal-box" style="max-width:640px;">
@@ -993,7 +1058,16 @@
ws.onopen = () => {
addLog('info', 'browser', 'Verbunden mit Diagnostic Server');
send({ action: 'load_chat_history' });
// Brain-Card initial laden (sonst zeigt sie "Lade...")
try { loadBrainStatus(); } catch {}
};
// Brain-Status periodisch refreshen damit die Card live bleibt
if (!window.__brainStatusInterval) {
window.__brainStatusInterval = setInterval(() => {
try { loadBrainStatus(); } catch {}
}, 15000);
}
ws.onclose = () => {
addLog('warn', 'browser', 'Verbindung zum Diagnostic Server verloren — Reconnect in 2s');
setTimeout(connectWS, 2000);
@@ -1230,12 +1304,7 @@
el.textContent = msg.error ? `Fehler: ${msg.error}` : msg.info;
return;
}
if (msg.type === 'core_auth') {
const el = document.getElementById('core-auth');
el.style.display = 'block';
el.textContent = msg.error ? `Fehler: ${msg.error}` : msg.info;
return;
}
// core_auth WS-Event entfernt — aria-core ist raus.
// Live SSH + Desktop
if (msg.type?.startsWith('live_ssh_')) { handleLiveSSH(msg); return; }
if (msg.type === 'desktop_status') { handleDesktop(msg); return; }
@@ -1310,32 +1379,15 @@
return;
}
// Settings (permissions_list/permissions_saved entfernt — Alles-oder-Nichts via --dangerously-skip-permissions)
if (msg.type === 'session_restarted') {
const s = document.getElementById('perms-status');
s.style.display = 'block';
if (msg.status === 'restarting') {
s.style.color = '#FFD60A';
s.textContent = 'aria-core wird neu gestartet...';
} else if (msg.status === 'ok') {
s.style.color = '#34C759';
s.textContent = msg.info || 'Session neu gestartet!';
} else {
s.style.color = '#FF6B6B';
s.textContent = 'Restart fehlgeschlagen: ' + (msg.error || '?');
}
return;
}
// session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus.
if (msg.type === 'model_info') {
const el = document.getElementById('setting-model');
const st = document.getElementById('model-status');
if (msg.model) el.value = msg.model;
st.textContent = msg.info || '';
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
return;
}
if (msg.type === 'openclaw_config') {
document.getElementById('openclaw-config').textContent = msg.config || msg.error || '(leer)';
if (el && msg.model) el.value = msg.model;
if (st) {
st.textContent = msg.info || msg.error || '';
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
}
return;
}
if (msg.type === 'response') { return; }
@@ -1422,15 +1474,12 @@
openTermModal('Claude Login Terminal (aria-proxy)', { action: 'proxy_login' });
}
function openCoreTerminal() {
document.getElementById('btn-core-term').disabled = true;
openTermModal('aria-core Shell', { action: 'core_terminal' });
}
// openCoreTerminal entfernt — aria-core ist raus.
function closeTermModal() {
document.getElementById('term-modal').classList.remove('open');
document.getElementById('btn-proxy-login').disabled = false;
document.getElementById('btn-core-term').disabled = false;
const proxyBtn = document.getElementById('btn-proxy-login');
if (proxyBtn) proxyBtn.disabled = false;
// Terminal aufraeumen
if (term) { term.dispose(); term = null; }
}
@@ -1496,12 +1545,8 @@
}
function updateState(state) {
// Gateway
const gw = state.gateway || {};
document.getElementById('gw-dot').className = `dot ${gw.status || 'disconnected'}`;
document.getElementById('gw-status').textContent =
(STATUS_LABELS[gw.status] || gw.status) + (gw.handshakeOk ? ' (Handshake OK)' : '');
document.getElementById('gw-error').textContent = gw.lastError || '';
// Brain-Card holt ihre Daten via loadBrainStatus() (fetch /api/brain/health).
// state.gateway ist Reststruktur aus OpenClaw-Zeit — wir ignorieren das hier.
// RVS
const rvs = state.rvs || {};
@@ -2280,7 +2325,6 @@
document.getElementById('rc-rvs-tls').value = String(cfg.RVS_TLS) === 'false' ? 'false' : 'true';
document.getElementById('rc-rvs-token').value = cfg.RVS_TOKEN || '';
document.getElementById('rc-auth-token').value = cfg.ARIA_AUTH_TOKEN || '';
document.getElementById('rc-compact-after').value = cfg.compactAfterMessages != null ? cfg.compactAfterMessages : 140;
statusEl.textContent = 'Geladen.';
statusEl.style.color = '#34C759';
loadOnboardingQR(); // QR bei Config-Wechsel neu generieren
@@ -2293,14 +2337,12 @@
async function saveRuntimeConfig() {
const statusEl = document.getElementById('rc-status');
statusEl.textContent = 'Speichere...';
const compactRaw = document.getElementById('rc-compact-after').value.trim();
const patch = {
RVS_HOST: document.getElementById('rc-rvs-host').value.trim(),
RVS_PORT: document.getElementById('rc-rvs-port').value.trim(),
RVS_TLS: document.getElementById('rc-rvs-tls').value,
RVS_TOKEN: document.getElementById('rc-rvs-token').value.trim(),
ARIA_AUTH_TOKEN: document.getElementById('rc-auth-token').value.trim(),
compactAfterMessages: compactRaw === '' ? 140 : Math.max(0, parseInt(compactRaw, 10) || 0),
};
try {
const resp = await fetch('/api/runtime-config', {
@@ -2617,6 +2659,7 @@
loadBrainStatus();
loadBrainMemoryList();
refreshImportFiles();
loadMetrics();
} else if (tab === 'files') {
loadFiles();
} else if (tab === 'skills') {
@@ -2807,6 +2850,7 @@
// ── Datei-Manager ──────────────────────────────────────
let filesCache = [];
const filesSelected = new Set(); // Set of paths
async function loadFiles() {
const listEl = document.getElementById('files-list');
@@ -2816,23 +2860,62 @@
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Unbekannter Fehler');
filesCache = d.files || [];
// Selection bereinigen — nicht mehr existierende Pfade raus
const existing = new Set(filesCache.map(f => f.path));
for (const p of [...filesSelected]) if (!existing.has(p)) filesSelected.delete(p);
renderFilesList();
} catch (e) {
if (listEl) listEl.innerHTML = `🔴 ${e.message}`;
}
}
function renderFilesList() {
const listEl = document.getElementById('files-list');
const infoEl = document.getElementById('files-info');
if (!listEl) return;
function getVisibleFiles() {
const q = (document.getElementById('files-search').value || '').toLowerCase();
const filter = document.getElementById('files-filter').value;
let files = filesCache.slice();
if (filter === 'aria') files = files.filter(f => f.fromAria);
else if (filter === 'user') files = files.filter(f => !f.fromAria);
if (q) files = files.filter(f => f.name.toLowerCase().includes(q));
if (infoEl) infoEl.textContent = `${files.length} von ${filesCache.length} Dateien`;
return files;
}
function toggleFileSelect(path) {
if (filesSelected.has(path)) filesSelected.delete(path);
else filesSelected.add(path);
renderFilesList();
}
function toggleSelectAll() {
const visible = getVisibleFiles();
const allSelected = visible.length > 0 && visible.every(f => filesSelected.has(f.path));
if (allSelected) visible.forEach(f => filesSelected.delete(f.path));
else visible.forEach(f => filesSelected.add(f.path));
renderFilesList();
}
function renderFilesList() {
const listEl = document.getElementById('files-list');
const infoEl = document.getElementById('files-info');
if (!listEl) return;
const files = getVisibleFiles();
const selectedCount = files.filter(f => filesSelected.has(f.path)).length;
const allChecked = files.length > 0 && selectedCount === files.length;
const bulkBtns = selectedCount > 0
? `<span style="color:#0096FF;font-weight:bold;">${selectedCount} ausgewählt</span>
<button class="btn" onclick="downloadSelected()" style="padding:2px 10px;font-size:11px;">⬇ Download ${selectedCount > 1 ? '(ZIP)' : ''}</button>
<button class="btn secondary" onclick="deleteSelected()" style="padding:2px 10px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Auswahl löschen</button>`
: '';
if (infoEl) {
infoEl.innerHTML = `
<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;color:#E0E0F0;">
<input type="checkbox" ${allChecked ? 'checked' : ''} onchange="toggleSelectAll()" style="cursor:pointer;">
<span>Alle markieren</span>
</label>
<span style="margin:0 8px;color:#555570;">·</span>
<span>${files.length} von ${filesCache.length} Dateien</span>
${bulkBtns ? '<span style="margin:0 8px;color:#555570;">·</span>' + bulkBtns : ''}
`;
}
if (!files.length) {
listEl.innerHTML = '(Keine Dateien gefunden)';
return;
@@ -2843,17 +2926,74 @@
const badge = f.fromAria
? '<span style="background:#0096FF22;color:#0096FF;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">ARIA</span>'
: '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;margin-right:6px;">User</span>';
return `<div style="padding:8px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:center;">
const checked = filesSelected.has(f.path) ? 'checked' : '';
const pathEsc = escapeHtml(f.path);
return `<div style="padding:8px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:8px;align-items:center;">
<input type="checkbox" ${checked} onchange="toggleFileSelect('${pathEsc}')" style="cursor:pointer;flex-shrink:0;">
<div style="flex:1;min-width:0;">
<div style="color:#E0E0F0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${badge}<strong>${escapeHtml(f.name)}</strong></div>
<div style="color:#555570;font-size:10px;">${fmtSize(f.size)} · ${fmtDate(f.mtime)}</div>
</div>
<button class="btn secondary" onclick="downloadFile('${encodeURIComponent(f.path)}')" style="padding:2px 8px;font-size:10px;" title="Herunterladen"></button>
<button class="btn secondary" onclick="deleteFile('${escapeHtml(f.path)}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
<button class="btn secondary" onclick="deleteFile('${pathEsc}','${escapeHtml(f.name)}')" style="padding:2px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
</div>`;
}).join('');
}
async function downloadSelected() {
const paths = [...filesSelected];
if (!paths.length) return;
if (paths.length === 1) {
// Einzelne Datei: normaler Download
downloadFile(encodeURIComponent(paths[0]));
return;
}
// Mehrere: POST mit paths-Array, Browser bekommt ZIP-Stream
try {
const r = await fetch('/api/files-download-zip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || ('HTTP ' + r.status));
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a');
a.href = url;
a.download = `aria-files-${ts}.zip`;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
} catch (e) {
alert('ZIP-Download fehlgeschlagen: ' + e.message);
}
}
async function deleteSelected() {
const paths = [...filesSelected];
if (!paths.length) return;
if (!confirm(`${paths.length} Datei(en) wirklich löschen?\n\nIn allen Chat-Bubbles werden sie als gelöscht markiert.`)) return;
try {
const r = await fetch('/api/files-delete-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
const d = await r.json();
if (d.ok) {
filesSelected.clear();
loadFiles();
} else {
alert('Bulk-Delete fehlgeschlagen: ' + (d.error || 'unbekannt'));
}
} catch (e) {
alert('Bulk-Delete fehlgeschlagen: ' + e.message);
}
}
function downloadFile(encPath) {
window.location.href = '/api/files-download?path=' + encPath;
}
@@ -2881,28 +3021,42 @@
// ── Gehirn-Tab ────────────────────────────────────────────
async function loadBrainStatus() {
const el = document.getElementById('brain-status');
if (!el) return;
// Es gibt ZWEI Brain-Status-Anzeigen: kompakte Card im Main-Tab
// (brain-dot + brain-status-short + brain-error) und die ausfuehrliche
// im Gehirn-Tab (brain-status). Beide muessen synchron updated werden.
const mainShort = document.getElementById('brain-status-short');
const mainDot = document.getElementById('brain-dot');
const mainErr = document.getElementById('brain-error');
const detail = document.getElementById('brain-status');
try {
const r = await fetch('/api/brain/health');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const st = d.status === 'ok' ? '🟢 online' : '🟡 ' + (d.status || 'unknown');
el.innerHTML = `${st} · ${d.memory_count ?? '?'} Memories · Qdrant: ${d.qdrant || '-'}`;
const ok = d.status === 'ok';
const st = ok ? '🟢 online' : '🟡 ' + (d.status || 'unknown');
const detailText = `${st} · ${d.memory_count ?? '?'} Memories · Qdrant: ${d.qdrant || '-'}`;
if (detail) detail.innerHTML = detailText;
if (mainShort) mainShort.textContent = ok ? 'online' : (d.status || 'unbekannt');
if (mainDot) mainDot.className = `dot ${ok ? 'connected' : 'disconnected'}`;
if (mainErr) mainErr.textContent = ok ? '' : (d.error || '');
} catch (e) {
el.innerHTML = `🔴 Brain nicht erreichbar (${e.message})`;
if (detail) detail.innerHTML = `🔴 Brain nicht erreichbar (${e.message})`;
if (mainShort) mainShort.textContent = 'nicht erreichbar';
if (mainDot) mainDot.className = 'dot disconnected';
if (mainErr) mainErr.textContent = e.message;
}
// Conversation-Stats (separater Endpoint)
const conv = document.getElementById('conversation-status');
if (!conv) return;
const infoBtn = `<button class="info-btn-small" onclick="showInfo('conversation')" title="Konversation — wie funktioniert das?"></button>`;
try {
const r2 = await fetch('/api/brain/conversation/stats');
if (!r2.ok) throw new Error('HTTP ' + r2.status);
const d2 = await r2.json();
const distillIcon = d2.needs_distill ? ' ⚠ Destillat bald fällig' : '';
conv.innerHTML = `Konversation: <strong>${d2.turns}</strong> Turns · Window: ${d2.max_window} · Schwelle: ${d2.distill_threshold}${distillIcon}`;
conv.innerHTML = `Konversation: <strong>${d2.turns}</strong> Turns · Window: ${d2.max_window} · Schwelle: ${d2.distill_threshold}${distillIcon} ${infoBtn}`;
} catch (e) {
conv.innerHTML = `Konversation: <span style="color:#555570;">${e.message}</span>`;
conv.innerHTML = `Konversation: <span style="color:#555570;">${e.message}</span> ${infoBtn}`;
}
}
@@ -2920,14 +3074,23 @@
}
async function resetConversation() {
if (!confirm('Konversation leeren?\n\nDer Rolling-Window-Verlauf wird komplett verworfen. Destillierte Facts bleiben in der DB.')) return;
if (!confirm('Konversation komplett zurücksetzen?\n\n• ARIAs Rolling-Window (Brain) wird geleert — sie "vergisst" die letzten Turns\n• Chat-Anzeige in der Diagnostic wird geleert\n\nDestillierte Facts + andere Memories bleiben in der Vector-DB.')) return;
try {
const r = await fetch('/api/brain/conversation/reset', { method: 'POST' });
const d = await r.json();
if (d.ok) {
// Beides parallel — Brain Window + Diagnostic chat_backup
const [brainR, histR] = await Promise.all([
fetch('/api/brain/conversation/reset', { method: 'POST' }),
fetch('/api/chat-history-clear', { method: 'POST' }),
]);
const brainOk = brainR.ok;
const histOk = histR.ok;
if (brainOk && histOk) {
// Chat-View leeren (Server broadcasted das eh, aber sicherheitshalber)
if (chatBox) chatBox.innerHTML = '';
const fsBox = document.getElementById('chat-box-fs');
if (fsBox) fsBox.innerHTML = '';
loadBrainStatus();
} else {
alert('Reset fehlgeschlagen');
alert(`Reset teilweise fehlgeschlagen — Brain: ${brainOk ? 'OK' : 'fail'}, History: ${histOk ? 'OK' : 'fail'}`);
}
} catch (e) {
alert('Reset fehlgeschlagen: ' + e.message);
@@ -3161,6 +3324,221 @@
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// ── Generisches Info-Modal — Aufruf: openInfoModal('Titel', '<p>HTML...</p>') ──
function openInfoModal(title, html) {
const t = document.getElementById('info-modal-title');
const b = document.getElementById('info-modal-body');
const m = document.getElementById('info-modal');
if (!t || !b || !m) return;
t.textContent = title;
b.innerHTML = html;
m.classList.add('open');
}
function closeInfoModal() {
const m = document.getElementById('info-modal');
if (m) m.classList.remove('open');
}
// ── Token / Calls Metrics ──────────────────────────────
// Anthropic-Subscription-Limits (Stand 2026, fuer Sonnet, "ca." weil
// Anthropic offiziell "fair use" sagt). Custom = User waehlt selbst.
const PLAN_LIMITS = {
pro: { h5: 45, label: 'Pro (~$20)' },
max5: { h5: 225, label: 'Max 5x (~$90-100)' },
max20: { h5: 900, label: 'Max 20x (~$200)' },
};
function getActivePlanLimit() {
const v = (document.getElementById('metrics-plan') || {}).value || 'max5';
if (v === 'custom') {
const n = parseInt((document.getElementById('metrics-custom-limit') || {}).value || '0', 10);
return { h5: n > 0 ? n : 225, label: 'Custom' };
}
return PLAN_LIMITS[v] || PLAN_LIMITS.max5;
}
function onMetricsPlanChange() {
const v = document.getElementById('metrics-plan').value;
const customRow = document.getElementById('metrics-custom-row');
if (customRow) customRow.style.display = v === 'custom' ? '' : 'none';
try {
localStorage.setItem('aria_metrics_plan', v);
if (v === 'custom') {
const n = document.getElementById('metrics-custom-limit').value;
if (n) localStorage.setItem('aria_metrics_custom_limit', n);
}
} catch {}
loadMetrics();
}
function restoreMetricsPlan() {
try {
const v = localStorage.getItem('aria_metrics_plan');
if (v) document.getElementById('metrics-plan').value = v;
const n = localStorage.getItem('aria_metrics_custom_limit');
if (n) document.getElementById('metrics-custom-limit').value = n;
onMetricsPlanChange();
} catch {}
}
function fmtTokens(n) {
if (n < 1000) return String(n);
if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
return (n / 1_000_000).toFixed(2) + 'M';
}
async function loadMetrics() {
try {
const r = await fetch('/api/brain/metrics/calls');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
renderMetrics(d);
} catch (e) {
const bar = document.getElementById('metrics-bar');
if (bar) bar.innerHTML = `<span style="color:#FF6B6B;font-size:11px;">Metrics nicht erreichbar: ${escapeHtml(e.message)}</span>`;
}
}
function renderMetrics(d) {
const setCell = (id, agg) => {
const el = document.getElementById(id);
if (!el) return;
const valueEl = el.querySelector('.metric-value');
if (valueEl) valueEl.textContent = `${agg.calls} Calls`;
// Sub-Zeile mit Tokens
let sub = el.querySelector('.metric-sub');
if (!sub) {
sub = document.createElement('div');
sub.className = 'metric-sub';
el.appendChild(sub);
}
sub.textContent = `${fmtTokens(agg.tokens_in)} in · ${fmtTokens(agg.tokens_out)} out`;
};
setCell('metrics-h1', d.h1);
setCell('metrics-h5', d.h5);
setCell('metrics-h24', d.h24);
setCell('metrics-d30', d.d30);
// 5h-Fenster gegen Plan-Limit: Warn-Klassen
const plan = getActivePlanLimit();
const limit = plan.h5;
const pct = limit > 0 ? Math.min(100, Math.round(d.h5.calls / limit * 100)) : 0;
const h5el = document.getElementById('metrics-h5');
if (h5el) {
h5el.classList.remove('warn', 'crit');
if (pct >= 90) h5el.classList.add('crit');
else if (pct >= 80) h5el.classList.add('warn');
}
// Progress-Bar oben
const bar = document.getElementById('metrics-bar');
if (bar) {
const color = pct >= 90 ? '#FF6B6B' : pct >= 80 ? '#FFD60A' : '#0096FF';
bar.innerHTML = `
<div style="font-size:11px;color:#8888AA;margin-bottom:4px;">
5h-Quota (${escapeHtml(plan.label)}): <strong style="color:${color};">${d.h5.calls} / ${limit}</strong>
<span style="color:#555570;"> (${pct}%)</span>
</div>
<div style="height:6px;background:#1E1E2E;border-radius:3px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${color};transition:width .3s;"></div>
</div>
`;
}
}
// Periodisch refreshen (alle 30s) wenn Gehirn-Tab offen
if (!window.__metricsInterval) {
window.__metricsInterval = setInterval(() => {
const t = document.getElementById('tab-brain');
if (t && t.classList.contains('visible')) {
try { loadMetrics(); } catch {}
}
}, 30000);
}
// Beim ersten Brain-Tab-Open: Plan restoren
setTimeout(restoreMetricsPlan, 100);
// Vor-definierte Info-Blocks
const INFO_TEXTS = {
'brain-status': {
title: 'Gehirn — Status',
html: `
<p><strong>online / offline</strong> — ob der <code>aria-brain</code> Container erreichbar ist (HTTP GET /health).</p>
<p><strong>N Memories</strong> — Anzahl der Punkte in der Vector-DB. Beinhaltet alle Typen: identity, rule, preference, tool, skill, fact, conversation, reminder.</p>
<p><strong>Qdrant: aria-qdrant:6333</strong> — Hostname + Port des Vector-DB-Containers. Der Brain spricht intern dorthin.</p>
`,
},
'conversation': {
title: 'Konversation — wie funktioniert das?',
html: `
<p><strong>Rolling Window:</strong> ARIA "sieht" pro Anfrage nur die letzten N Turns einer einzelnen, durchgehenden Konversation. Kein Sessions, kein Multi-Thread.</p>
<ul>
<li><strong>Turns</strong> — Anzahl aller Nachrichten (User + ARIA) seit dem letzten Destillat oder Reset.</li>
<li><strong>Window: 50</strong> — die letzten 50 Turns wandern in den Prompt. Aelteste fallen raus, sobald die Schwelle ueberschritten ist.</li>
<li><strong>Schwelle: 60</strong> — bei mehr als 60 Turns triggert Brain automatisch das Destillat (die 30 aeltesten werden zu fact-Memories verdichtet, Token-Budget bleibt konstant).</li>
</ul>
<p><strong>⚗ Jetzt destillieren:</strong> manueller Trigger fuer das Destillat (kostet einen Claude-Call). Verdichtet die aeltesten 30 Turns zu Fakten + entfernt sie aus dem Window.</p>
<p><strong>🧹 Konversation komplett zuruecksetzen:</strong> leert beides — ARIAs Rolling-Window (Brain) UND die Chat-Anzeige hier (chat_backup.jsonl). Destillierte Facts + alle anderen Memories in der Vector-DB <em>bleiben</em>.</p>
<p style="margin-top:8px;color:#FFD60A;font-size:12px;">⚠ Falls "Turns: 0" obwohl du oben Chat-Eintraege siehst: chat_backup.jsonl (Anzeige) und conversation.jsonl (Brain-Kontext) sind getrennte Stores. Alte chat_backup-Eintraege koennen aus OpenClaw-Zeit stammen. Reset-Button leert beides.</p>
`,
},
'memories': {
title: 'Memories — Hot vs. Cold',
html: `
<p><strong>Pinned (Hot Memory)</strong> 📌 — landet bei JEDER Anfrage im System-Prompt. Hier gehoeren rein: Identitaet, Sicherheitsregeln, Benutzer-Praeferenzen, Tool-Freigaben, Kern-Skills.</p>
<p><strong>Cold Memory</strong> — semantisch durchsucht. Pro Anfrage werden die 5 aehnlichsten Punkte zur User-Frage in den Prompt eingehaengt.</p>
<p><strong>Typen:</strong></p>
<ul>
<li><strong>identity</strong> — wer ARIA ist (Name, Persoenlichkeit)</li>
<li><strong>rule</strong> — Sicherheits-/Werte-Regeln</li>
<li><strong>preference</strong> — User-Profile</li>
<li><strong>tool</strong> — Tool-Freigaben + Infrastruktur</li>
<li><strong>skill</strong> — Faehigkeiten (verlinkt mit /data/skills/)</li>
<li><strong>fact</strong> — Wissens-Fakten (oft aus Destillaten)</li>
<li><strong>conversation</strong> — destillierte Konversations-Erkenntnisse</li>
<li><strong>reminder</strong> — Termine, Aufgaben</li>
</ul>
<p><strong>Such-Feld:</strong> semantische Suche via Embedder + Qdrant. Findet sinngemaess, nicht nur Stichworte.</p>
`,
},
'metrics': {
title: 'Token / Calls — Quota-Monitoring',
html: `
<p>Anthropic gibt fuer ihre Subscriptions keine exakten Token-Limits raus,
sondern <strong>"fair use"</strong>. Kursierende Schaetzungen (Stand 2026, fuer Sonnet):</p>
<ul>
<li><strong>Pro (~$20):</strong> ca. 45 Calls pro 5h-Fenster</li>
<li><strong>Max 5x (~$90-100):</strong> ca. 225 Calls pro 5h-Fenster</li>
<li><strong>Max 20x (~$200):</strong> ca. 900 Calls pro 5h-Fenster</li>
</ul>
<p>Wichtig: <strong>HTTP-Call ≠ User-Frage.</strong> Pro User-Frage:</p>
<ul>
<li>Einfache Antwort ohne Tool: <strong>1 Call</strong></li>
<li>Mit 1 Tool (Skill): <strong>2 Calls</strong> (Tool-Entscheidung + finale Antwort)</li>
<li>Multi-Tool-Chain: bis zu <strong>8 Calls</strong> (MAX_TOOL_ITERATIONS)</li>
<li>Bei >60 Turns Konversation: <strong>+1 Destillat-Call</strong> im Hintergrund</li>
</ul>
<p>Token-Werte sind Schaetzung (chars / 4, Anthropic-Heuristik) — nicht exakt,
aber gut genug fuer Quota-Monitoring. Persistent in <code>/data/metrics.jsonl</code>,
Auto-Rotate bei 50k Eintraegen.</p>
<p><strong>Warn-Schwellen:</strong> 5h-Counter wird gelb bei 80%, rot bei 90% des Plan-Limits.</p>
`,
},
'bootstrap': {
title: 'Bootstrap & Migration — die drei Wege',
html: `
<p><strong>1. Aus brain-import/ migrieren</strong> 🔵 — Parser fuer die <code>.md</code>-Dateien im Repo (AGENT.md, USER.md, TOOLING.md). Schreibt sie als atomare pinned Memories. Idempotent — Re-Run ersetzt nur die Migration-Punkte, eigene Memories bleiben.</p>
<p><strong>2. Bootstrap-Snapshot</strong> 🟡 — kleines JSON, NUR die pinned Memories. Export = aktueller Stand als Datei. Import = ALLE aktuell pinned werden ersetzt. Cold Memory bleibt unangetastet.</p>
<p><strong>3. Komplettes Gehirn</strong> 🔴 — tar.gz mit allem (Memories + Skills + Qdrant-DB). Backup + Restore. Import ueberschreibt ALLES.</p>
`,
},
};
function showInfo(key) {
const cfg = INFO_TEXTS[key];
if (cfg) openInfoModal(cfg.title, cfg.html);
}
function brainExport() {
// Browser folgt der Download-Header-Antwort automatisch
window.location.href = '/api/brain-export';
+95
View File
@@ -1361,6 +1361,77 @@ const server = http.createServer((req, res) => {
});
fs.createReadStream(safe).pipe(res);
return;
} else if (req.url === "/api/files-download-zip" && req.method === "POST") {
// Multi-Datei-Download als ZIP. Body: {paths: ["/shared/uploads/...", ...]}.
// Streamt zip stdout direkt in die Response.
let body = "";
req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
let paths = [];
try { paths = (JSON.parse(body || "{}").paths || []); } catch { paths = []; }
// Whitelist: nur /shared/uploads/, existieren muessen sie
paths = paths
.map(p => path.resolve(String(p)))
.filter(p => p.startsWith("/shared/uploads/") && fs.existsSync(p));
if (!paths.length) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Keine gueltigen Pfade" }));
return;
}
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const fname = `aria-files-${ts}.zip`;
res.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fname}"`,
});
// zip -j: junk paths (Dateien ohne Verzeichnisstruktur ablegen)
const { spawn } = require("child_process");
const zip = spawn("zip", ["-j", "-q", "-", ...paths]);
zip.stdout.pipe(res);
let stderr = "";
zip.stderr.on("data", d => stderr += d.toString());
zip.on("close", code => {
if (code !== 0 && code !== 12) {
log("error", "server", `zip exit ${code}: ${stderr.slice(0, 200)}`);
}
});
req.on("close", () => { if (!zip.killed) zip.kill("SIGTERM"); });
});
return;
} else if (req.url === "/api/files-delete-batch" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
let paths = (JSON.parse(body || "{}").paths || []);
paths = paths
.map(p => path.resolve(String(p)))
.filter(p => p.startsWith("/shared/uploads/"));
const deleted = [];
const errors = [];
for (const p of paths) {
try {
if (fs.existsSync(p)) fs.unlinkSync(p);
deleted.push(p);
broadcast({ type: "file_deleted", path: p });
sendToRVS_raw({ type: "file_deleted", payload: { path: p }, timestamp: Date.now() });
try {
fs.appendFileSync("/shared/config/chat_backup.jsonl",
JSON.stringify({ type: "file_deleted", path: p, ts: Date.now(), by: "user" }) + "\n");
} catch {}
} catch (e) {
errors.push({ path: p, error: e.message });
}
}
log("info", "server", `Bulk-Delete: ${deleted.length} OK, ${errors.length} Fehler`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, deleted, errors }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/files-delete" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 4096) req.destroy(); });
@@ -1448,6 +1519,30 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/chat-history-clear" && req.method === "POST") {
// Leert die Diagnostic-Anzeige-History (chat_backup.jsonl) UND broadcastet
// chat_cleared an alle RVS-Clients (App leert lokal). Brain's
// Rolling-Window (conversation.jsonl) ist davon unabhaengig — Caller
// sollte zusaetzlich /api/brain/conversation/reset triggern.
log("warn", "server", "HTTP /api/chat-history-clear");
try {
const file = "/shared/config/chat_backup.jsonl";
if (fs.existsSync(file)) fs.unlinkSync(file);
// Browser-Clients: leere chat_history
broadcast({ type: "chat_history", messages: [] });
// App via RVS: chat_cleared
sendToRVS_raw({
type: "chat_cleared",
payload: { ts: Date.now() },
timestamp: Date.now(),
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
return;
} else if (req.url === "/api/wipe-all" && req.method === "POST") {
// Komplett-Reset — Gedaechtnis, Stimmen, Config alle weg. SSH-Keys
// und .env bleiben, RVS-Anbindung bleibt. Brain + Qdrant werden
+50 -13
View File
@@ -212,22 +212,55 @@ Wichtige Mechanismen:
- [x] RVS Nachrichten vom Smartphone gehen durch
- [x] SSH Volume read-write fuer Proxy (kein -F Workaround mehr)
## Offen
## Brain — Phase B (komplett)
### Brain (Phase B — der grosse Refactor laeuft)
Der grosse Refactor weg von OpenClaw zu eigener Brain-Architektur — alle 4 Punkte
durch. ARIA hat jetzt eigenes Gedaechtnis (Vector-DB), eigenen Loop, eigene
Skills mit Tool-Use.
- [x] aria-brain Container-Skeleton (FastAPI + Qdrant + sentence-transformers)
- [x] Memory CRUD via Diagnostic-Gehirn-Tab (Add/Edit/Delete + Search + Filter)
- [x] Gehirn-Export/Import als tar.gz (komplett: Memories + Skills + Qdrant)
- [x] Voice-Bridge: aria-core-spezifische Logik raus (doctor_fix, aria_restart, aria_session_reset, compact_after)
- [x] aria-core komplett aus docker-compose.yml raus, Watchdog raus
- [x] Diagnostic: Wipe-All-Button (Memory + Stimmen + Settings)
- [x] Voice Export/Import (Diagnostic + XTTS-Bridge auf Gaming-PC)
### Infrastruktur
- [x] aria-brain Container (FastAPI + Qdrant + sentence-transformers, MiniLM multilingual)
- [x] aria-core (OpenClaw) abgerissen — Tag `v0.1.2.0` als Archiv
- [x] docker-compose komplett umgebaut: brain + qdrant + bridge + diagnostic + proxy
- [x] Voice-Bridge: aria-core-Logik raus (doctor_fix, aria_restart, compact_after) → durch Brain-HTTP-Call ersetzt
- [x] Sprachmodell-Setting in runtime.json (brainModel) — Diagnostic kann Modell live wechseln, Brain-Restart noetig
### Memory / Vector-DB
- [x] Memory CRUD via Diagnostic-Gehirn-Tab (Add/Edit/Delete + Suche + Type/Pinned-Filter)
- [x] **Migration aus brain-import/** (Phase B Punkt 2) — Parser fuer AGENT.md/USER.md/TOOLING.md, atomare Punkte mit migration_key (idempotent)
- [x] **Bootstrap-Snapshot** (Phase B Punkt 2) — Export/Import nur pinned Memories als JSON
- [x] **Komplettes Gehirn** Export/Import als tar.gz (Memories + Skills + Qdrant)
### Conversation-Loop (Phase B Punkt 3)
- [x] Single-Chat UI + Rolling Window (50 Turns)
- [x] Memory-Destillat: bei >60 Turns automatisch 30 aelteste → fact-Memories via Claude-Call
- [x] Hot Memory (pinned) + Cold Memory (Top-5 semantisch) im System-Prompt
- [x] Manueller Destillat-Trigger + Konversation-Reset (Brain + Diagnostic chat_backup gleichzeitig)
- [x] App-Chat-Sync: verpasste Nachrichten beim Reconnect + chat_cleared Live-Update
### Skills-System (Phase B Punkt 4)
- [x] Python-only Skills (local-venv pro Skill, eigene pip-Pakete)
- [x] Tool-Use im Brain: skill_create als Meta-Tool, dynamische run_<skill> pro aktivem Skill
- [x] Harte Schwelle dokumentiert: pip-Install → IMMER Skill (Brain hat keinen Persistenz ausser /data/skills/)
- [x] Diagnostic Skills-Tab: Liste, README, Logs pro Run, Activate/Deactivate/Delete, Export/Import als tar.gz
- [x] skill_created Live-Notification: gelbe Bubble in App + Diagnostic sobald ARIA selbst einen Skill anlegt
### Diagnostic / App Features (drumherum)
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
- [x] Wipe-All-Button (Memory + Stimmen + Settings)
- [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf Gamebox)
- [x] F5/Whisper-Settings als JSON-Bundle Export/Import
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Delete spiegelt sich live in den Chat-Bubbles
- [ ] **Phase B Punkt 2:** Migration `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rules / Preferences / Tools)
- [ ] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI + Rolling Window + Memory-Destillat)
- [ ] **Phase B Punkt 4:** Skills-System (Manifest, venv/local-bin, README pro Skill, Diagnostic-Skills-Tab, Export/Import)
- [x] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
- [x] Info-Buttons mit Modal-Erklaerungen im Gehirn-Tab
- [x] Token/Call-Metrics + Subscription-Quota-Tracking: pro Claude-Call ein Log-Eintrag mit Token-Schaetzung (chars/4). Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat + Progress-Bar gegen Plan-Limit (Pro=45/5h, Max 5x=225/5h, Max 20x=900/5h, Custom). Warn-Schwelle 80%, kritisch 90%.
## Offen
### App Features
- [ ] Chat-History zuverlaessiger laden (AsyncStorage Race Condition)
@@ -238,3 +271,7 @@ Wichtige Mechanismen:
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
- [ ] Tool-Use-Verifikation: Live-Test ob claude-max-api-proxy `tools` und `tool_calls` sauber durchreicht
- [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
+3
View File
@@ -25,6 +25,9 @@ const ALLOWED_TYPES = new Set([
"xtts_export_voice", "xtts_voice_exported",
"xtts_import_voice", "xtts_voice_imported",
"skill_created",
"chat_history_request", "chat_history_response", "chat_cleared",
"file_delete_batch_request", "file_delete_batch_response",
"file_zip_request", "file_zip_response",
"xtts_delete_voice",
"voice_preload", "voice_ready",
"stt_request", "stt_response",