Compare commits

..
8 Commits
Author SHA1 Message Date
duffyduck 8491fb2af7 release: bump version to 0.1.2.3 2026-05-11 23:48:00 +02:00
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
duffyduckandClaude Opus 4.7 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
14 changed files with 1124 additions and 116 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 . .
+319 -23
View File
@@ -120,6 +120,14 @@
/* 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;
@@ -470,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;">
@@ -546,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>
@@ -713,6 +724,37 @@
</div>
</div>
<div class="settings-section">
<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;">
@@ -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,16 +3021,29 @@
// ── 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');
@@ -3186,6 +3339,126 @@
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': {
@@ -3229,6 +3502,29 @@
<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: `
+82 -4
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(); });
@@ -1449,15 +1520,22 @@ 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).
// Brain's Rolling-Window (conversation.jsonl) ist davon unabhaengig —
// der Caller sollte zusaetzlich /api/brain/conversation/reset triggern.
// 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);
// Broadcast: alle Browser-Clients sollen ihre lokale Chat-View leeren
// 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) {
+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",