diff --git a/android/src/components/MemoryDetailModal.tsx b/android/src/components/MemoryDetailModal.tsx new file mode 100644 index 0000000..230ba56 --- /dev/null +++ b/android/src/components/MemoryDetailModal.tsx @@ -0,0 +1,364 @@ +/** + * Memory-Detail-Modal — Anzeige + Edit eines einzelnen Memory-Eintrags. + * + * Zwei Modi: + * - read-only: zeigt alle Felder + Anhang-Vorschau (Klick auf Bild = Vollbild) + * - edit: Form mit Save/Delete/Anhang-hochladen + * + * Memory-Daten werden beim Oeffnen aus dem Brain (via brainApi → RVS) frisch + * gezogen. Optimistic Updates sind explizit nicht da — der DB-Stand ist die + * Truth. + */ + +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Image, + Modal, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker'; +import RNFS from 'react-native-fs'; + +import brainApi, { Memory, MemoryAttachment } from '../services/brainApi'; + +interface Props { + memoryId: string | null; + visible: boolean; + onClose: () => void; + onDeleted?: (id: string) => void; +} + +const TYPE_OPTIONS = [ + { value: 'identity', label: 'identity (FEST)' }, + { value: 'rule', label: 'rule (FEST)' }, + { value: 'preference', label: 'preference (FEST)' }, + { value: 'tool', label: 'tool (FEST)' }, + { value: 'skill', label: 'skill (FEST)' }, + { value: 'fact', label: 'fact (Cold)' }, + { value: 'conversation', label: 'conversation (Cold)' }, + { value: 'reminder', label: 'reminder (Cold)' }, +]; + +export const MemoryDetailModal: React.FC = ({ memoryId, visible, onClose, onDeleted }) => { + const [memory, setMemory] = useState(null); + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(null); + + // Edit-Felder + const [eTitle, setETitle] = useState(''); + const [eContent, setEContent] = useState(''); + const [eCategory, setECategory] = useState(''); + const [eTags, setETags] = useState(''); + const [ePinned, setEPinned] = useState(false); + + // Bild-Vollbild + const [fullscreen, setFullscreen] = useState(null); + + // Memory laden beim Oeffnen + useEffect(() => { + if (!visible || !memoryId) { + setMemory(null); setEditing(false); setErr(null); return; + } + setLoading(true); setErr(null); + brainApi.getMemory(memoryId) + .then(m => { + setMemory(m); + setETitle(m.title || ''); + setEContent(m.content || ''); + setECategory(m.category || ''); + setETags((m.tags || []).join(', ')); + setEPinned(!!m.pinned); + }) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }, [visible, memoryId]); + + const reload = () => { + if (!memoryId) return; + setLoading(true); + brainApi.getMemory(memoryId) + .then(m => setMemory(m)) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }; + + const onSave = async () => { + if (!memoryId) return; + setSaving(true); setErr(null); + try { + const tags = eTags.split(',').map(t => t.trim()).filter(Boolean); + const m = await brainApi.updateMemory(memoryId, { + title: eTitle.trim(), + content: eContent.trim(), + category: eCategory.trim(), + tags, + pinned: ePinned, + }); + setMemory(m); + setEditing(false); + } catch (e: any) { + setErr(String(e?.message || e)); + } finally { + setSaving(false); + } + }; + + const onDelete = () => { + if (!memoryId || !memory) return; + Alert.alert( + 'Memory loeschen?', + `"${memory.title}"\n\nWird permanent aus der DB entfernt, inkl. aller Anhaenge.`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loeschen', + style: 'destructive', + onPress: async () => { + try { + await brainApi.deleteMemory(memoryId); + if (onDeleted) onDeleted(memoryId); + onClose(); + } catch (e: any) { + Alert.alert('Fehler', String(e?.message || e)); + } + }, + }, + ], + ); + }; + + const onPickAndUpload = async () => { + if (!memoryId) return; + try { + const picked: DocumentPickerResponse[] = await DocumentPicker.pick({ + type: [DocumentPicker.types.images, DocumentPicker.types.pdf, DocumentPicker.types.allFiles], + copyTo: 'cachesDirectory', + }); + for (const f of picked) { + setBusy(`Lade ${f.name}…`); + // RNFS lesen → base64 → API + const localPath = (f.fileCopyUri || f.uri).replace(/^file:\/\//, ''); + const b64 = await RNFS.readFile(localPath, 'base64'); + await brainApi.uploadAttachment(memoryId, f.name || 'datei', b64); + } + setBusy(null); + reload(); + } catch (e: any) { + setBusy(null); + if (DocumentPicker.isCancel(e)) return; + Alert.alert('Upload-Fehler', String(e?.message || e)); + } + }; + + const onDeleteAttachment = (att: MemoryAttachment) => { + if (!memoryId) return; + Alert.alert( + 'Anhang loeschen?', + `"${att.name}"`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loeschen', + style: 'destructive', + onPress: async () => { + try { + const m = await brainApi.deleteAttachment(memoryId, att.name); + setMemory(m); + } catch (e: any) { + Alert.alert('Fehler', String(e?.message || e)); + } + }, + }, + ], + ); + }; + + const onTapAttachment = async (att: MemoryAttachment) => { + if (!memoryId) return; + if ((att.mime || '').startsWith('image/')) { + try { + setBusy('Lade Bild…'); + const data = await brainApi.getAttachmentBytes(memoryId, att.name); + // Temp-File schreiben damit es zeigen kann + const safe = att.name.replace(/[^A-Za-z0-9._-]/g, '_'); + const localPath = `${RNFS.CachesDirectoryPath}/memory_${memoryId}_${safe}`; + await RNFS.writeFile(localPath, data.base64, 'base64'); + setBusy(null); + setFullscreen('file://' + localPath); + } catch (e: any) { + setBusy(null); + Alert.alert('Fehler', String(e?.message || e)); + } + } else { + Alert.alert('Anhang', `${att.name}\n${att.mime}\n${att.size} Byte\n\nPfad: ${att.path}`); + } + }; + + return ( + + + + + {editing ? 'Memory bearbeiten' : 'Memory-Detail'} + + × + + + + + {loading ? ( + + ) : err && !memory ? ( + {err} + ) : memory ? ( + editing ? ( + + Typ + {memory.type} (kann hier nicht geaendert werden) + + Titel + + + Inhalt + + + Kategorie + + + Tags (komma-getrennt) + + + + + 📌 Pinned (immer im System-Prompt) + + + {err ? {err} : null} + + + setEditing(false)} disabled={saving}> + Abbrechen + + + {saving ? 'Speichere…' : 'Speichern'} + + + + ) : ( + + + {memory.pinned ? '📌 ' : ''}{memory.title} + setEditing(true)} style={s.iconBtn}> + + + + + {memory.type}{memory.category ? ` · [${memory.category}]` : ''} + + {(memory.tags || []).length > 0 ? ( + + {memory.tags.map(t => {t})} + + ) : null} + + {memory.content} + + 📎 Anhaenge + {(memory.attachments || []).length === 0 ? ( + (keine) + ) : ( + (memory.attachments || []).map((a) => { + const isImage = (a.mime || '').startsWith('image/'); + return ( + + onTapAttachment(a)}> + {isImage ? '🖼️' : '📄'} + + {a.name} + {a.mime} · {Math.round(a.size/1024)} KB + + + onDeleteAttachment(a)} style={s.attDelete}> + 🗑 + + + ); + }) + )} + + ⬆ Datei anhaengen + + {busy ? {busy} : null} + + + angelegt: {(memory.created_at || '').slice(0,16).replace('T',' ')}{'\n'} + geaendert: {(memory.updated_at || '').slice(0,16).replace('T',' ')}{'\n'} + id: {memory.id} + + + + 🗑 Memory komplett loeschen + + + ) + ) : null} + + + + + setFullscreen(null)}> + setFullscreen(null)}> + {fullscreen ? : null} + + + + ); +}; + +const s = StyleSheet.create({ + backdrop: { flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'flex-end' }, + box: { backgroundColor:'#0D0D1A', borderTopLeftRadius:12, borderTopRightRadius:12, maxHeight:'92%' }, + header: { flexDirection:'row', justifyContent:'space-between', alignItems:'center', padding:14, borderBottomColor:'#1E1E2E', borderBottomWidth:1 }, + title: { color:'#FFD60A', fontWeight:'bold', fontSize:15 }, + closeX: { color:'#8888AA', fontSize:24, paddingHorizontal:6 }, + body: { padding:14 }, + err: { color:'#FF6B6B', fontSize:12, marginTop:8 }, + label: { color:'#8888AA', fontSize:11, marginBottom:3, marginTop:8 }, + input: { backgroundColor:'#080810', borderColor:'#1E1E2E', borderWidth:1, borderRadius:4, padding:8, color:'#E0E0F0', fontSize:13 }, + bigTitle: { color:'#E0E0F0', fontWeight:'bold', fontSize:16, flex:1, marginRight:6 }, + iconBtn: { padding:6, backgroundColor:'#1E1E2E', borderRadius:6 }, + iconBtnText: { color:'#0096FF', fontSize:14 }, + meta: { color:'#8888AA', fontSize:11, marginTop:4 }, + tagsRow: { flexDirection:'row', flexWrap:'wrap', gap:4, marginTop:6 }, + tag: { backgroundColor:'#1E1E2E', color:'#8888AA', fontSize:10, paddingHorizontal:6, paddingVertical:2, borderRadius:8 }, + contentBlock: { color:'#E0E0F0', fontSize:13, marginTop:12, lineHeight:18 }, + sectionHead: { color:'#0096FF', fontSize:11, marginTop:14, marginBottom:6, textTransform:'uppercase', letterSpacing:0.5 }, + attRow: { flexDirection:'row', alignItems:'center', backgroundColor:'#080810', padding:8, borderRadius:6, marginBottom:4, gap:6 }, + attDelete: { padding:4 }, + timestamps: { color:'#555570', fontSize:10, marginTop:12, fontFamily:'monospace' }, + btn: { paddingVertical:10, paddingHorizontal:14, borderRadius:6, alignItems:'center' }, + btnPrimary: { backgroundColor:'#0096FF' }, + btnSecondary: { backgroundColor:'#1E1E2E' }, + btnDanger: { backgroundColor:'#3B1010', borderWidth:1, borderColor:'#FF6B6B' }, + btnText: { color:'#fff', fontSize:13, fontWeight:'600' }, + fsBack: { flex:1, backgroundColor:'rgba(0,0,0,0.95)', justifyContent:'center', alignItems:'center' }, + fsImg: { width:'95%', height:'85%' }, +}); + +export default MemoryDetailModal; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index c0933ca..ea46116 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -28,6 +28,7 @@ import RNFS from 'react-native-fs'; import { SvgUri } from 'react-native-svg'; import { Dimensions } from 'react-native'; import ZoomableImage from '../components/ZoomableImage'; +import MemoryDetailModal from '../components/MemoryDetailModal'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; @@ -231,6 +232,7 @@ const ChatScreen: React.FC = () => { // Genauer State (off/armed/conversing) fuer UI-Feedback am Button const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing'>('off'); const [fullscreenImage, setFullscreenImage] = useState(null); + const [memoryDetailId, setMemoryDetailId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchVisible, setSearchVisible] = useState(false); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist @@ -1337,8 +1339,13 @@ const ChatScreen: React.FC = () => { '🧠 ARIA hat etwas gemerkt'; const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A'; const borderColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A'; + const openable = !!m.id && action !== 'deleted'; + const Wrapper: any = openable ? TouchableOpacity : View; + const wrapperProps = openable + ? { onPress: () => setMemoryDetailId(m.id || null), activeOpacity: 0.7 } + : {}; return ( - + {headline} @@ -1378,8 +1385,10 @@ const ChatScreen: React.FC = () => { ); })} - ARIA-Memory · {time} - + + ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''} + + ); } @@ -1816,6 +1825,14 @@ const ChatScreen: React.FC = () => { )} + {/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */} + setMemoryDetailId(null)} + onDeleted={() => setMemoryDetailId(null)} + /> + {/* Bild-Vollbild Modal */} setFullscreenImage(null)}> diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts new file mode 100644 index 0000000..5069b6f --- /dev/null +++ b/android/src/services/brainApi.ts @@ -0,0 +1,206 @@ +/** + * Brain-API-Client fuer die App. + * + * Die App hat keinen direkten HTTP-Zugriff aufs Brain (nur via RVS). Wir + * tunneln alle Memory-Operationen ueber den generischen brain_request / + * brain_response RVS-Channel den die Bridge implementiert. + * + * Pattern: pro Call eine eindeutige requestId, Listener wartet auf passende + * brain_response, Promise loest auf / wird abgelehnt bei status>=400. + */ + +import rvs from './rvs'; + +type AnyJson = any; + +interface PendingRequest { + resolve: (data: AnyJson) => void; + reject: (err: Error) => void; + timer: ReturnType; + expectBinary?: boolean; +} + +const pending = new Map(); +let installed = false; + +function _ensureListener() { + if (installed) return; + installed = true; + rvs.onMessage((msg: any) => { + if (!msg || msg.type !== 'brain_response') return; + const p = msg.payload || {}; + const reqId: string = p.requestId || ''; + const handler = pending.get(reqId); + if (!handler) return; + pending.delete(reqId); + clearTimeout(handler.timer); + const status: number = Number(p.status || 0); + if (status >= 200 && status < 300) { + if (handler.expectBinary) { + handler.resolve({ base64: p.base64 || '', contentType: p.contentType || '' }); + } else { + handler.resolve(p.json !== undefined ? p.json : (p.text !== undefined ? p.text : null)); + } + } else { + const detail = (p.json && p.json.detail) || p.text || `HTTP ${status}`; + handler.reject(new Error(`Brain ${status}: ${detail}`)); + } + }); +} + +let _nextId = 0; +function _newRequestId(): string { + _nextId += 1; + return `brain_${Date.now().toString(36)}_${_nextId}`; +} + +interface SendOpts { + method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: AnyJson; + bodyBase64?: string; + contentType?: string; + expectBinary?: boolean; + timeoutMs?: number; +} + +function _send(path: string, opts: SendOpts = {}): Promise { + _ensureListener(); + return new Promise((resolve, reject) => { + const requestId = _newRequestId(); + const timer = setTimeout(() => { + if (pending.delete(requestId)) { + reject(new Error(`Brain-Timeout fuer ${path}`)); + } + }, opts.timeoutMs || 30000); + pending.set(requestId, { resolve, reject, timer, expectBinary: opts.expectBinary }); + rvs.send('brain_request' as any, { + requestId, + method: opts.method || 'GET', + path, + ...(opts.body !== undefined ? { body: opts.body } : {}), + ...(opts.bodyBase64 ? { bodyBase64: opts.bodyBase64 } : {}), + ...(opts.contentType ? { contentType: opts.contentType } : {}), + }); + }); +} + +// ── Typen ──────────────────────────────────────────────────────────── + +export interface MemoryAttachment { + name: string; + mime: string; + size: number; + path: string; +} + +export interface Memory { + id: string; + type: string; + title: string; + content: string; + pinned: boolean; + category: string; + source: string; + tags: string[]; + created_at: string; + updated_at: string; + conversation_id?: string | null; + score?: number | null; + attachments?: MemoryAttachment[]; +} + +// ── Memory CRUD ────────────────────────────────────────────────────── + +export const brainApi = { + /** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */ + getMemory(id: string): Promise { + return _send(`/memory/get/${encodeURIComponent(id)}`); + }, + + /** Liste aller Memories, optional nach Type gefiltert. */ + listMemories(opts: { type?: string; limit?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (opts.type) qs.set('type', opts.type); + qs.set('limit', String(opts.limit || 500)); + return _send(`/memory/list?${qs.toString()}`); + }, + + /** Volltext-Substring-Suche. */ + searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise { + const qs = new URLSearchParams({ q }); + if (opts.type) qs.set('type', opts.type); + qs.set('include_pinned', String(opts.includePinned !== false)); + qs.set('k', String(opts.k || 50)); + return _send(`/memory/search-text?${qs.toString()}`); + }, + + /** Semantische Suche (Embedder). */ + searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise { + const qs = new URLSearchParams({ q }); + if (opts.type) qs.set('type', opts.type); + qs.set('include_pinned', String(opts.includePinned !== false)); + qs.set('k', String(opts.k || 10)); + qs.set('score_threshold', String(opts.threshold ?? 0.30)); + return _send(`/memory/search?${qs.toString()}`); + }, + + /** Memory anlegen. */ + saveMemory(body: { + type: string; + title: string; + content: string; + pinned?: boolean; + category?: string; + tags?: string[]; + }): Promise { + return _send('/memory/save', { + method: 'POST', + body: { source: 'app', ...body }, + }); + }, + + /** Memory aktualisieren (Patch — nur uebergebene Felder werden geaendert). */ + updateMemory(id: string, body: Partial>): Promise { + return _send(`/memory/update/${encodeURIComponent(id)}`, { + method: 'PATCH', + body, + }); + }, + + /** Memory loeschen. */ + deleteMemory(id: string): Promise<{ deleted: string }> { + return _send(`/memory/delete/${encodeURIComponent(id)}`, { + method: 'DELETE', + timeoutMs: 15000, + }); + }, + + // ── Anhaenge ──────────────────────────────────────────────────────── + + /** Datei als Anhang an die Memory haengen (Base64-Upload). */ + uploadAttachment(memoryId: string, name: string, base64: string): Promise { + return _send(`/memory/${encodeURIComponent(memoryId)}/attachments`, { + method: 'POST', + body: { name, data_base64: base64 }, + timeoutMs: 120000, + }); + }, + + /** Anhang loeschen. */ + deleteAttachment(memoryId: string, filename: string): Promise { + return _send( + `/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`, + { method: 'DELETE' }, + ); + }, + + /** Anhang-Bytes holen (fuer Vorschau / Download). Liefert Base64. */ + getAttachmentBytes(memoryId: string, filename: string): Promise<{ base64: string; contentType: string }> { + return _send( + `/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`, + { expectBinary: true, timeoutMs: 60000 }, + ); + }, +}; + +export default brainApi; diff --git a/aria-brain/main.py b/aria-brain/main.py index 6da77f3..6b5f681 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -167,6 +167,16 @@ def health(): # ─── Memory-Endpoints ───────────────────────────────────────────────── +@app.get("/memory/get/{point_id}", response_model=MemoryOut) +def memory_get(point_id: str): + """Einzelner Memory mit allen Feldern (inkl. Anhaengen). + Pfad-Prefix /memory/get/ vermeidet Konflikt mit /memory/list, /memory/save etc.""" + m = store().get(point_id) + if not m: + raise HTTPException(404, f"Memory {point_id} nicht gefunden") + return MemoryOut.from_point(m) + + @app.get("/memory/stats") def memory_stats(): s = store() diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index f24556d..5814920 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1824,6 +1824,72 @@ class ARIABridge: logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error")) return + elif msg_type == "brain_request": + # Generischer RVS-Proxy fuer die Brain-HTTP-API. + # payload: {requestId, method, path, body?, bodyBase64?, contentType?} + # - method: GET | POST | PATCH | DELETE + # - path: z.B. "/memory/list" oder "/memory/get/" + # - body: JSON-Objekt (wird als JSON encoded) + # - bodyBase64: rohe Bytes als Base64 (fuer Upload mit contentType) + # - contentType: default application/json + # Antwort als brain_response {requestId, status, json?, base64?}. + req_id = payload.get("requestId") or "" + method = (payload.get("method") or "GET").upper() + path = payload.get("path") or "" + if not req_id or not path or not path.startswith("/"): + logger.warning("[rvs] brain_request ungueltig: %r", payload) + return + brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") + url = brain_url.rstrip("/") + path + headers: dict[str, str] = {} + data: Optional[bytes] = None + ct = payload.get("contentType") or "application/json" + if payload.get("bodyBase64"): + try: + data = base64.b64decode(payload["bodyBase64"]) + except Exception: + data = None + if data is not None: + headers["Content-Type"] = ct + elif payload.get("body") is not None: + data = json.dumps(payload["body"]).encode("utf-8") + headers["Content-Type"] = "application/json" + logger.info("[rvs] brain_request %s %s (%d Byte)", method, path, len(data or b"")) + + def _do_call(): + try: + req = urllib.request.Request(url, data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=120) as r: + return r.status, r.read(), r.headers.get("Content-Type", "") + except urllib.error.HTTPError as e: + try: + body = e.read() + except Exception: + body = b"" + return e.code, body, e.headers.get("Content-Type", "") if e.headers else "" + except Exception as exc: + return None, str(exc).encode("utf-8"), "text/plain" + + status, body_bytes, response_ct = await asyncio.get_event_loop().run_in_executor(None, _do_call) + out: dict = {"requestId": req_id, "status": status or 0} + if response_ct and "json" in response_ct: + try: + out["json"] = json.loads(body_bytes.decode("utf-8", errors="ignore")) + except Exception: + out["text"] = body_bytes.decode("utf-8", errors="ignore")[:2000] + elif response_ct and "text" in response_ct: + out["text"] = body_bytes.decode("utf-8", errors="ignore")[:4000] + else: + # Binaer (z.B. attachment-download) → base64 zurueck + out["base64"] = base64.b64encode(body_bytes).decode("ascii") + out["contentType"] = response_ct or "application/octet-stream" + await self._send_to_rvs({ + "type": "brain_response", + "payload": out, + "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") diff --git a/rvs/server.js b/rvs/server.js index acab02c..42254a9 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -30,6 +30,7 @@ const ALLOWED_TYPES = new Set([ "location_update", "location_tracking", "chat_history_request", "chat_history_response", "chat_cleared", "delete_message_request", "chat_message_deleted", + "brain_request", "brain_response", "file_delete_batch_request", "file_delete_batch_response", "file_zip_request", "file_zip_response", "xtts_delete_voice",