Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8491fb2af7 | |||
| f61864282e | |||
| b2f7d6dda2 | |||
| eeedcc4781 | |||
| 5cf8cab5bd | |||
| 3ae9e19524 | |||
| 0ec4b00879 | |||
| b6b4b1b4d9 | |||
| 950a9d009c | |||
| 693542ef19 | |||
| d12f356ebe |
@@ -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)
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.2.2",
|
||||
"version": "0.1.2.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,9 +677,8 @@ 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}}>
|
||||
{(() => {
|
||||
) : (() => {
|
||||
// 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);
|
||||
@@ -654,15 +686,120 @@ const SettingsScreen: React.FC = () => {
|
||||
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 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`;
|
||||
return files.map(f => (
|
||||
<View key={f.path} style={{
|
||||
backgroundColor:'#0D0D1A', padding:12, borderRadius:8, marginBottom:8,
|
||||
flexDirection:'row', alignItems:'center', gap:8,
|
||||
|
||||
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={{
|
||||
width:18, height:18, borderRadius:3,
|
||||
borderWidth:2, borderColor: selected ? '#0096FF' : '#555570',
|
||||
backgroundColor: selected ? '#0096FF' : 'transparent',
|
||||
alignItems:'center', justifyContent:'center',
|
||||
}}>
|
||||
{selected && <Text style={{color:'#fff', fontSize:11, fontWeight:'bold'}}>✓</Text>}
|
||||
</View>
|
||||
<View style={{flex:1}}>
|
||||
<View style={{flexDirection:'row', alignItems:'center'}}>
|
||||
<View style={{
|
||||
@@ -697,11 +834,13 @@ const SettingsScreen: React.FC = () => {
|
||||
>
|
||||
<Text style={{color:'#FF6B6B', fontSize:18}}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
));
|
||||
})()}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</Modal>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
+456
-78
@@ -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>
|
||||
<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)">
|
||||
<button class="btn secondary" onclick="document.getElementById('voice-settings-import-file').click()" style="padding:4px 10px;font-size:11px;">⬆ Import</button>
|
||||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
<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()">×</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 || '';
|
||||
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 === 'openclaw_config') {
|
||||
document.getElementById('openclaw-config').textContent = msg.config || msg.error || '(leer)';
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user