From 30c1dd74734587f7114b2345958632bf41c2a73d Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 24 May 2026 17:24:03 +0200 Subject: [PATCH] feat(app+brain): App-Bugfixes + Skill-Mgmt-Tools + Voice-Speed persistent + Skill-Browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-Bugs: - Trigger-Liste war leer: brainApi.listTriggers() cast'te {triggers: [...]} direkt als Array, t.sort() warf — TriggerBrowser blieb leer. Fix: unwrap. - GPS-Tracking startete erst bei SettingsScreen-Mount, nicht beim App-Boot. Wenn Stefan direkt in den Chat ging, blieb GPS aus. Fix: restoreFromStorage() in App.tsx useEffect. - Text in Chat-Bubbles nicht markierbar / kein Copy-Mechanismus: Bubble jetzt Pressable mit onLongPress + neues ⎘-Icon in Status-Row → openBubbleActions(). Alert-Menu mit "Ganzen Text teilen" + pro extrahierte URL/Mail/Tel eine eigene Option. Share.share() — keine neuen Native-Deps noetig. Brain — Skill-Mgmt: - ARIA legte beim Skill-Umbau neue Versionen mit Suffix an (Skill-Friedhof), weil sie kein Update/Delete-Tool kannte. Zwei neue META_TOOLS in agent.py: skill_update (kann entry_code, readme, pip_packages, args, description, active patchen — venv wird bei pip_packages-Aenderung rebuilt) + skill_delete. - skills.py update_skill um entry_code/readme/pip_packages erweitert, venv-Rebuild bei pip-Aenderung. Bridge — Voice-Speed persistent: - _next_speed_override war pro-Request-Override ohne Persistenz. Bei Diagnostic-Chats / Trigger-Replies ohne vorherigen App-Chat fiel der Speed auf 1.0 zurueck, ebenso nach Bridge-Restart. Jetzt: _persistent_xtts_speed aus voice_config.json (xttsSpeed), wird nach jedem App-chat mit speed autopersistiert. TTS-Generation faellt zurueck: per-Request > persistent > 1.0. App — Feature 6: - SkillBrowser.tsx: Liste aller Skills, Toggle aktiv/inaktiv, Detail-Modal mit Args-Inputs, Ausfuehren mit Live-stdout/stderr, Logs der letzten 20 Runs, Loeschen. Settings-Sektion "Skills" (🛠️) zwischen Trigger und Protokoll. brainApi.listSkills/getSkill/runSkill/updateSkill/deleteSkill/ getSkillLogs ergaenzt. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/App.tsx | 8 + android/src/components/SkillBrowser.tsx | 470 ++++++++++++++++++++++++ android/src/screens/ChatScreen.tsx | 75 +++- android/src/screens/SettingsScreen.tsx | 17 +- android/src/services/brainApi.ts | 79 +++- aria-brain/agent.py | 101 +++++ aria-brain/skills.py | 45 +++ bridge/aria_bridge.py | 75 +++- 8 files changed, 862 insertions(+), 8 deletions(-) create mode 100644 android/src/components/SkillBrowser.tsx diff --git a/android/App.tsx b/android/App.tsx index d4b11e5..1f4cbcd 100644 --- a/android/App.tsx +++ b/android/App.tsx @@ -16,6 +16,7 @@ import SettingsScreen from './src/screens/SettingsScreen'; import rvs from './src/services/rvs'; import { initLogger, installGlobalCrashReporter } from './src/services/logger'; import { acquireBackgroundAudio } from './src/services/backgroundAudio'; +import gpsTrackingService from './src/services/gpsTracking'; // --- Navigation --- @@ -99,6 +100,13 @@ const App: React.FC = () => { }; initBackground(); + // GPS-Tracking-Status aus AsyncStorage wiederherstellen (war + // bisher nur an SettingsScreen-Mount gekoppelt; wenn Stefan + // direkt im Chat startete blieb GPS aus bis er Settings oeffnete). + gpsTrackingService.restoreFromStorage().catch((err) => { + console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err); + }); + // Beim Beenden: Verbindung sauber trennen return () => { rvs.disconnect(); diff --git a/android/src/components/SkillBrowser.tsx b/android/src/components/SkillBrowser.tsx new file mode 100644 index 0000000..4ede5db --- /dev/null +++ b/android/src/components/SkillBrowser.tsx @@ -0,0 +1,470 @@ +/** + * Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run, + * Logs und Loeschen. + * + * Eingesetzt von SettingsScreen → Sektion "Skills". + * + * Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT + * ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene + * (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + FlatList, + Modal, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import brainApi, { Skill } from '../services/brainApi'; + +const COL_ACTIVE = '#34C759'; +const COL_INACTIVE = '#555570'; +const COL_ARIA = '#FFD60A'; +const COL_STEFAN = '#0096FF'; + +function relTime(iso: string | null | undefined): string { + if (!iso) return '—'; + const t = new Date(iso).getTime(); + if (!t) return '—'; + const diffSec = Math.floor((Date.now() - t) / 1000); + if (diffSec < 60) return `vor ${diffSec}s`; + if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`; + if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`; + return `vor ${Math.floor(diffSec / 86400)}d`; +} + +export const SkillBrowser: React.FC = () => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [detail, setDetail] = useState(null); + + const load = useCallback(() => { + setLoading(true); setErr(null); + brainApi.listSkills() + .then(s => { + s.sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1; + return (a.name || '').localeCompare(b.name || ''); + }); + setItems(s); + }) + .catch(e => setErr(String(e?.message || e))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const visible = items.filter(s => { + if (filter === 'active') return s.active; + if (filter === 'inactive') return !s.active; + return true; + }); + + const toggleActive = (s: Skill) => { + brainApi.updateSkill(s.name, { active: !s.active }) + .then(() => load()) + .catch(e => Alert.alert('Fehler', String(e?.message || e))); + }; + + const renderItem = ({ item }: { item: Skill }) => { + const isAria = (item.author || '').toLowerCase() === 'aria'; + const authorColor = isAria ? COL_ARIA : COL_STEFAN; + const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan'; + return ( + setDetail(item)}> + + + {authorLabel} + {item.name} + + {item.description} + {item.setup_error ? ( + + ⚠ Setup-Fehler: {item.setup_error} + + ) : null} + + {item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)} + + + toggleActive(item)} + trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }} + thumbColor="#E0E0F0" + /> + + ); + }; + + return ( + + + {(['all', 'active', 'inactive'] as const).map(f => ( + setFilter(f)} + > + + {f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'} + + + ))} + + + {'↻'} + + + + {err ? {err} : null} + + {loading && items.length === 0 ? ( + + ) : ( + s.name} + renderItem={renderItem} + nestedScrollEnabled={true} + ListEmptyComponent={ + + {items.length === 0 + ? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")' + : '(keine Treffer für diesen Filter)'} + + } + contentContainerStyle={{paddingBottom: 20}} + /> + )} + + {detail ? ( + setDetail(null)} + onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }} + /> + ) : null} + + ); +}; + +// ── Detail-Modal mit Run + Logs + Delete ───────────────────────────── + +interface DetailProps { + skill: Skill; + onClose: () => void; + onReload: () => void; +} + +const SkillDetailModal: React.FC = ({ skill, onClose, onReload }) => { + const [argValues, setArgValues] = useState>({}); + const [running, setRunning] = useState(false); + const [runResult, setRunResult] = useState<{ + ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number; + } | null>(null); + const [logs, setLogs] = useState(null); + const [loadingLogs, setLoadingLogs] = useState(false); + + const args = Array.isArray(skill.args) ? skill.args : []; + + const setArg = (name: string, value: string) => + setArgValues(prev => ({ ...prev, [name]: value })); + + const run = () => { + setRunning(true); setRunResult(null); + const argsObj: Record = {}; + for (const a of args) { + if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') { + argsObj[a.name] = argValues[a.name]; + } + } + brainApi.runSkill(skill.name, argsObj) + .then(r => setRunResult(r)) + .catch(e => setRunResult({ + ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0, + })) + .finally(() => setRunning(false)); + }; + + const loadLogs = () => { + setLoadingLogs(true); + brainApi.getSkillLogs(skill.name, 20) + .then(setLogs) + .catch(e => Alert.alert('Logs-Fehler', String(e?.message || e))) + .finally(() => setLoadingLogs(false)); + }; + + const remove = () => { + Alert.alert( + 'Skill loeschen?', + `"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`, + [ + { text: 'Abbrechen', style: 'cancel' }, + { + text: 'Loeschen', + style: 'destructive', + onPress: () => { + brainApi.deleteSkill(skill.name) + .then(() => { onReload(); onClose(); }) + .catch(e => Alert.alert('Fehler', String(e?.message || e))); + }, + }, + ], + ); + }; + + return ( + + + + {skill.name} + + {'✕'} + + + + + Beschreibung + {skill.description} + + + execution: {skill.execution} · entry: {skill.entry} + author: {skill.author || '?'} · version: {skill.version || '?'} + {skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)} + {skill.setup_error ? ( + setup_error: {skill.setup_error} + ) : null} + {Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? ( + pip: {skill.requires!.pip!.join(', ')} + ) : null} + + + {/* Args-Inputs */} + {args.length > 0 ? ( + <> + Argumente + {args.map((a: any) => ( + + + {a.name}{a.required ? ' *' : ''} {a.description ? `— ${a.description}` : ''} + + setArg(a.name, v)} + placeholder={a.type || 'string'} + placeholderTextColor="#444460" + autoCapitalize="none" + autoCorrect={false} + /> + + ))} + + ) : null} + + + + + {running ? 'läuft...' : '▶ Ausführen'} + + + + 📜 Logs + + + + {!skill.active ? ( + + Skill ist deaktiviert — toggle in der Liste zum Aktivieren. + + ) : null} + + {/* Run-Result */} + {runResult ? ( + + + {runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s + + {runResult.stdout ? ( + <> + stdout: + {runResult.stdout} + + ) : null} + {runResult.stderr ? ( + <> + stderr: + {runResult.stderr} + + ) : null} + + ) : null} + + {/* Logs */} + {loadingLogs ? ( + + ) : logs ? ( + + Letzte Runs (Top 20) + {logs.length === 0 ? ( + (keine Logs) + ) : logs.map((log, idx) => ( + + + {log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s + + {log.stdout ? ( + + {String(log.stdout).slice(0, 300)} + + ) : null} + + ))} + + ) : null} + + + + + + + 🗑 Loeschen + + + + Schliessen + + + + + ); +}; + +// ── Styles ─────────────────────────────────────────────────────────── + +const s = StyleSheet.create({ + toolbar: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 10, + paddingVertical: 8, + backgroundColor: '#0D0D1A', + borderBottomWidth: 1, + borderBottomColor: '#1E1E2E', + }, + chip: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 12, + backgroundColor: '#1A1A2E', + }, + chipActive: { + backgroundColor: '#FFD60A', + }, + iconBtn: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 6, + backgroundColor: '#1A1A2E', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 14, + backgroundColor: '#0D0D1A', + borderBottomWidth: 1, + borderBottomColor: '#1E1E2E', + }, + err: { + color: '#FF6B6B', + padding: 12, + fontSize: 12, + }, + modal: { + flex: 1, + backgroundColor: '#0D0D1A', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#1E1E2E', + }, + modalTitle: { + color: '#E0E0F0', + fontSize: 16, + fontWeight: '700', + flex: 1, + marginRight: 12, + }, + modalFooter: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderTopWidth: 1, + borderTopColor: '#1E1E2E', + gap: 8, + }, + label: { + color: '#8888AA', + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginTop: 8, + marginBottom: 4, + }, + input: { + backgroundColor: '#1A1A2E', + borderWidth: 1, + borderColor: '#1E1E2E', + borderRadius: 6, + color: '#E0E0F0', + padding: 10, + fontSize: 14, + }, + metaBox: { + backgroundColor: '#1A1A2E', + borderRadius: 6, + padding: 10, + marginTop: 6, + gap: 4, + }, + meta: { + color: '#8888AA', + fontSize: 12, + }, + btn: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: 'transparent', + }, +}); + +export default SkillBrowser; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index b5acd2a..2c56bfe 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -22,6 +22,8 @@ import { AppState, NativeModules, Alert, + Pressable, + Share, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNFS from 'react-native-fs'; @@ -1987,7 +1989,7 @@ const ChatScreen: React.FC = () => { } return ( - { // Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt @@ -1995,6 +1997,9 @@ const ChatScreen: React.FC = () => { // unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck. itemHeights.current.set(item.id, e.nativeEvent.layout.height); }} + onLongPress={() => openBubbleActions(item)} + delayLongPress={500} + android_ripple={null} > {/* Anhang-Vorschau */} {item.attachments?.map((att, idx) => ( @@ -2125,6 +2130,15 @@ const ChatScreen: React.FC = () => { ) : null} {time} + {item.text.length > 0 ? ( + openBubbleActions(item)} + accessibilityLabel="Aktionen" + > + {'⎘'} + + ) : null} {isUser && item.deliveryStatus ? ( item.deliveryStatus === 'failed' && item.clientMsgId ? ( { ) ) : null} - + + ); + }; + + // Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon). + // Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert + // teilen kann ohne den umliegenden Text mitzunehmen. + const extractCopyables = (text: string): { label: string; value: string }[] => { + const items: { label: string; value: string }[] = []; + const urlRe = /https?:\/\/[^\s<>"']+/gi; + const mailRe = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g; + const telRe = /(?:\+?\d[\d ()/-]{6,}\d)/g; + const seen = new Set(); + const push = (label: string, value: string) => { + const trimmed = value.trim().replace(/[,;.)\]}>]+$/g, ''); + if (!trimmed || seen.has(trimmed)) return; + seen.add(trimmed); + items.push({ label, value: trimmed }); + }; + (text.match(urlRe) || []).forEach(u => push('URL', u)); + (text.match(mailRe) || []).forEach(m => push('E-Mail', m)); + (text.match(telRe) || []).forEach(t => push('Telefon', t)); + return items.slice(0, 5); // max 5 items, mehr wird unleserlich + }; + + // Long-Press oder ⎘-Icon auf einer Bubble. Zeigt einen Alert mit + // "Text teilen" (= System-Share-Sheet, dort gibt's auch Zwischenablage) + // sowie pro extrahierte URL/E-Mail/Telefonnummer eine Option um + // gezielt nur dieses Item zu teilen. + const openBubbleActions = (item: ChatMessage) => { + const text = showSystemHints ? item.text : stripSystemHints(item.text); + if (!text) return; + const copyables = extractCopyables(text); + const buttons: any[] = [ + { + text: '📋 Ganzen Text teilen', + onPress: () => Share.share({ message: text }).catch(() => {}), + }, + ]; + for (const c of copyables) { + buttons.push({ + text: `📎 ${c.label}: ${c.value.slice(0, 32)}${c.value.length > 32 ? '…' : ''}`, + onPress: () => Share.share({ message: c.value }).catch(() => {}), + }); + } + buttons.push({ text: 'Abbrechen', style: 'cancel' }); + Alert.alert( + 'Bubble-Aktionen', + copyables.length > 0 + ? 'Was moechtest du teilen / kopieren?' + : 'Text in System-Share-Sheet oeffnen (dort "In Zwischenablage" verfuegbar).', + buttons, ); }; @@ -3126,6 +3191,12 @@ const styles = StyleSheet.create({ fontSize: 12, color: '#FF6B6B', }, + bubbleCopyIcon: { + fontSize: 13, + color: '#8888AA', + marginLeft: 6, + opacity: 0.7, + }, fullscreenOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.95)', diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index f7aad21..1f55812 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -56,6 +56,7 @@ import gpsTrackingService from '../services/gpsTracking'; import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio'; import MemoryBrowser from '../components/MemoryBrowser'; import TriggerBrowser from '../components/TriggerBrowser'; +import SkillBrowser from '../components/SkillBrowser'; import { isVerboseLogging, setVerboseLogging } from '../services/logger'; import { isWakeReadySoundEnabled, @@ -106,6 +107,7 @@ const SETTINGS_SECTIONS = [ { id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' }, { id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' }, { id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' }, + { id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' }, { id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' }, { id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' }, ] as const; @@ -928,7 +930,7 @@ const SettingsScreen: React.FC = () => { // Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat // (Memory, Trigger), den outer Scroll deaktivieren — Android-nested- // scrolling laesst sonst nur in eine Richtung scrollen. - scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'} + scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills'} > {currentSection === null && ( @@ -1809,6 +1811,19 @@ const SettingsScreen: React.FC = () => { )} + {/* === Skills === */} + {currentSection === 'skills' && (<> + Skills + + Wiederverwendbare Python-Skills die ARIA selbst gebaut hat oder die Du importiert hast. + Toggle aktiv/inaktiv, Tap fuer Details + Run + Logs. Code-Aenderungen macht ARIA via + ihr skill_update Brain-Tool — hier nur Manifest-Felder + Run + Cleanup. + + + + + )} + {/* === Logs === */} {currentSection === 'protocol' && (<> Protokoll diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index 5f42752..c98e0ae 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -121,6 +121,24 @@ export interface Memory { attachments?: MemoryAttachment[]; } +/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */ +export interface Skill { + name: string; + description: string; + execution: string; // local-venv | local-bin | bash + entry: string; // run.py | run.sh + args?: any[]; // [{name, type, required, description}] + requires?: { pip?: string[]; binaries?: string[] }; + active: boolean; + created_at?: string; + updated_at?: string; + last_used?: string | null; + use_count?: number; + version?: string; + author?: string; // "aria" | "stefan" + setup_error?: string; +} + /** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */ export interface Trigger { name: string; @@ -236,9 +254,12 @@ export const brainApi = { // ── Triggers ──────────────────────────────────────────────────────── - /** Liste aller Trigger (aktive + inaktive). */ + /** Liste aller Trigger (aktive + inaktive). + * Brain returnt {triggers: [...]} — wir unwrappen damit der Caller einfach + * t.sort/filter/map nutzen kann. Ohne das Unwrap warf t.sort() eine + * TypeError-Exception und der TriggerBrowser blieb leer. */ listTriggers(): Promise { - return _send('/triggers/list'); + return _send('/triggers/list').then((r: any) => Array.isArray(r) ? r : (r?.triggers || [])); }, /** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */ @@ -301,6 +322,60 @@ export const brainApi = { timeoutMs: 15000, }); }, + + // ── Skills ──────────────────────────────────────────────────────── + + /** Liste aller Skills (aktive + inaktive). Brain returnt {skills: [...]}. */ + listSkills(): Promise { + return _send('/skills/list').then((r: any) => Array.isArray(r) ? r : (r?.skills || [])); + }, + + /** Einzelnen Skill holen (inkl. setup_error, last_used, use_count). */ + getSkill(name: string): Promise { + return _send(`/skills/${encodeURIComponent(name)}`); + }, + + /** Skill ausfuehren (mit args als ENV ARG_XXX). Skill-Run kann lange dauern, + * 5 min Default-Timeout. */ + runSkill(name: string, args: Record = {}): Promise<{ + ok: boolean; exit_code: number; stdout: string; stderr: string; + duration_sec: number; log_path?: string; + }> { + return _send('/skills/run', { + method: 'POST', + body: { name, args, timeout_sec: 300 }, + timeoutMs: 320000, + }); + }, + + /** Skill-Manifest aendern (description, active, args...). Code-Aenderungen + * gehen ueber ARIAs eigene skill_update-Tool — die App-UI sollte sie + * NICHT direkt anbieten (zu fehleranfaellig). */ + updateSkill(name: string, body: Partial<{ + description: string; + active: boolean; + args: any[]; + version: string; + }>): Promise { + return _send(`/skills/${encodeURIComponent(name)}`, { + method: 'PATCH', + body, + timeoutMs: 15000, + }); + }, + + /** Skill loeschen (samt venv + logs). */ + deleteSkill(name: string): Promise<{ deleted: string }> { + return _send(`/skills/${encodeURIComponent(name)}`, { + method: 'DELETE', + timeoutMs: 15000, + }); + }, + + /** Letzte Run-Logs eines Skills. */ + getSkillLogs(name: string, limit: number = 20): Promise { + return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`); + }, }; export default brainApi; diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 71a04d1..bdf67fc 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -123,6 +123,67 @@ META_TOOLS = [ "parameters": {"type": "object", "properties": {}}, }, }, + { + "type": "function", + "function": { + "name": "skill_update", + "description": ( + "Aktualisiere einen EXISTIERENDEN Skill statt eine zweite Version " + "mit `-v2`/`-new`/`-fixed` Suffix anzulegen. Stefan hasst Skill-" + "Friedhoefe. Wenn Du `youtube2mp3` umbauen sollst → `skill_update` " + "auf den bestehenden, NICHT `skill_create` mit neuem Namen.\n\n" + "Du kannst gleichzeitig `entry_code` (Python-Code austauschen), " + "`readme`, `pip_packages` (bei Aenderung wird die venv automatisch " + "neu aufgebaut), `args`, `description` und `active` setzen. Felder " + "die Du weglaesst bleiben unberuehrt.\n\n" + "WENN Du Dir bei einem grundlegenden API-Bruch unsicher bist ob " + "der Skill noch zum Namen passt: lieber `skill_delete` + " + "`skill_create` mit neuem semantischen Namen statt eines " + "halbgaren Updates." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Bestehender Skill-Name"}, + "entry_code": {"type": "string", "description": "Neuer Python-Code (optional)"}, + "readme": {"type": "string", "description": "Neuer README-Inhalt (optional)"}, + "pip_packages": { + "type": "array", + "items": {"type": "string"}, + "description": "Neue pip-Pakete (ueberschreibt komplette Liste; triggert venv-Rebuild)", + }, + "args": { + "type": "array", + "items": {"type": "object"}, + "description": "Neues Args-Schema (optional)", + }, + "description": {"type": "string", "description": "Neue Beschreibung (optional)"}, + "active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "skill_delete", + "description": ( + "Loescht einen Skill samt venv und Logs. Nutze das wenn:\n" + "1. Stefan explizit sagt der Skill soll weg\n" + "2. Du eine alte Skill-Version losgeworden bist nachdem `skill_create` " + "mit besserem Namen erfolgreich war (Aufraeumen statt Skill-Friedhof)\n" + "3. Ein Skill grundlegend kaputt und ein Update sich nicht mehr lohnt — " + "in dem Fall bestaetige vorher kurz bei Stefan.\n\n" + "Nicht rueckholbar." + ), + "parameters": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + }, + }, { "type": "function", "function": { @@ -746,6 +807,46 @@ class Agent: f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}" for s in items ) + if name == "skill_update": + skill_name = (arguments.get("name") or "").strip() + if not skill_name: + return "FEHLER: name ist Pflicht." + patch: dict = {} + for k in ("entry_code", "readme", "description", "args", "active"): + if k in arguments and arguments[k] is not None: + patch[k] = arguments[k] + if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list): + patch["pip_packages"] = arguments["pip_packages"] + if not patch: + return "FEHLER: keine Felder zum Update angegeben." + try: + manifest = skills_mod.update_skill(skill_name, patch) + except ValueError as exc: + return f"FEHLER: {exc}" + # Side-Channel-Event als skill_created getarnt — gleiche Bubble-Mechanik + # in App/Diagnostic; das Update soll fuer Stefan ebenfalls sichtbar werden. + self._pending_events.append({ + "type": "skill_created", + "skill": { + "name": manifest["name"], + "description": manifest.get("description", ""), + "execution": manifest.get("execution", ""), + "active": manifest.get("active", True), + "setup_error": manifest.get("setup_error"), + "updated": True, + }, + }) + changed = ", ".join(sorted(patch.keys())) + return f"OK — Skill '{skill_name}' aktualisiert ({changed}). active={manifest['active']}" + if name == "skill_delete": + skill_name = (arguments.get("name") or "").strip() + if not skill_name: + return "FEHLER: name ist Pflicht." + try: + skills_mod.delete_skill(skill_name) + except ValueError as exc: + return f"FEHLER: {exc}" + return f"OK — Skill '{skill_name}' geloescht." if name.startswith("run_"): skill_name = name[len("run_"):] res = skills_mod.run_skill(skill_name, args=arguments) diff --git a/aria-brain/skills.py b/aria-brain/skills.py index c616d36..87b170f 100644 --- a/aria-brain/skills.py +++ b/aria-brain/skills.py @@ -194,14 +194,59 @@ def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None: def update_skill(name: str, patch: dict) -> dict: + """Aktualisiert einen bestehenden Skill. Manifest-Felder ueber den + `allowed`-Filter, Code-Aenderungen ueber dedizierte Keys: + + - `entry_code` (str) → ueberschreibt run.py / run.sh + - `readme` (str) → ueberschreibt README.md + - `pip_packages` (list) → ueberschreibt requirements.txt + venv-Rebuild + (nur bei local-venv) + """ manifest = read_manifest(name) if manifest is None: raise ValueError(f"Skill '{name}' nicht gefunden") + d = _skill_dir(name) allowed = {"description", "args", "requires", "active", "version", "entry"} for k, v in patch.items(): if k in allowed: manifest[k] = v + + # Code austauschen + if "entry_code" in patch and patch["entry_code"]: + execution = manifest.get("execution", "local-venv") + if execution == "local-venv": + entry_path = d / "run.py" + entry_path.write_text(patch["entry_code"], encoding="utf-8") + else: + entry_path = d / "run.sh" + content = patch["entry_code"] if patch["entry_code"].startswith("#!") else "#!/usr/bin/env bash\nset -euo pipefail\n" + patch["entry_code"] + entry_path.write_text(content, encoding="utf-8") + entry_path.chmod(0o755) + + # README austauschen + if "readme" in patch and patch["readme"] is not None: + (d / "README.md").write_text(patch["readme"], encoding="utf-8") + + # pip_packages geaendert → requirements.txt + venv neu aufbauen + if "pip_packages" in patch and manifest.get("execution") == "local-venv": + pip_packages = patch["pip_packages"] or [] + (d / "requirements.txt").write_text("\n".join(pip_packages) + "\n", encoding="utf-8") + # venv loeschen + neu aufbauen, damit alte Pakete weg sind + venv = d / "venv" + if venv.exists(): + shutil.rmtree(venv, ignore_errors=True) + try: + _setup_venv(d, pip_packages) + # Falls vorher wegen Setup-Error deaktiviert war: jetzt frei + manifest.pop("setup_error", None) + manifest["active"] = patch.get("active", True) + except Exception as exc: + manifest["active"] = False + manifest["setup_error"] = str(exc)[:500] + logger.warning("Skill %s: venv-Rebuild fehlgeschlagen: %s", name, exc) + write_manifest(name, manifest) + logger.info("Skill aktualisiert: %s (keys=%s)", name, sorted(patch.keys())) return manifest diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 3391127..72b073f 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -519,6 +519,10 @@ class ARIABridge: self.xtts_voice = "" self._f5tts_config: dict = {} self._flux_config: dict = {} + # Persistente TTS-Speed (App-Setting), wird aus voice_config.json + # gelesen + bei config-Broadcasts (siehe handle config in chat) + # geupdated. Fallback wenn der Per-Request-Override fehlt. + self._persistent_xtts_speed: Optional[float] = None vc: dict = {} # Gespeicherte Voice-Config laden try: @@ -528,6 +532,19 @@ class ARIABridge: vc = json.load(f) self.tts_enabled = vc.get("ttsEnabled", True) self.xtts_voice = vc.get("xttsVoice", "") + # Persistente TTS-Speed: vorher war's nur per-Chat-Override + # (App schickte speed mit jeder Nachricht). Bei Diagnostic-Chat + # OHNE App-Vor-Chat blieb _next_speed_override=None → 1.0. + # Jetzt persistent — Bridge greift bei TTS immer auf den + # zuletzt von der App gesetzten Wert zurueck. + try: + persisted_speed = float(vc.get("xttsSpeed", 1.0)) + if 0.1 <= persisted_speed <= 5.0: + self._persistent_xtts_speed: Optional[float] = persisted_speed + else: + self._persistent_xtts_speed = None + except (TypeError, ValueError): + self._persistent_xtts_speed = None # F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet, # damit die f5tts-bridge auf der Gamebox die Settings auch nach # Restart wiederbekommt — sonst stuende sie auf Hard-Defaults) @@ -1185,7 +1202,16 @@ class ARIABridge: # TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis # zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird. xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '') - xtts_speed = self._next_speed_override or 1.0 + # Speed-Reihenfolge: Per-Request-Override (App schickte gerade) > + # persistierter App-Setting (voice_config.json xttsSpeed) > 1.0 default. + # Damit greift die App-Speed auch bei Diagnostic-Chats / Trigger- + # Replies / Bridge-Restart, ohne dass die App vorher noch mal getippt + # haben muss. + xtts_speed = ( + self._next_speed_override + or getattr(self, "_persistent_xtts_speed", None) + or 1.0 + ) tts_text = tts_text_preview or text if not tts_text: @@ -1274,6 +1300,8 @@ class ARIABridge: "xttsVoice": getattr(self, "xtts_voice", ""), "whisperModel": self.stt_engine.model_size, } + if getattr(self, "_persistent_xtts_speed", None) is not None: + payload["xttsSpeed"] = self._persistent_xtts_speed payload.update(getattr(self, "_f5tts_config", {}) or {}) payload.update(getattr(self, "_flux_config", {}) or {}) await self._send_to_rvs({ @@ -1285,6 +1313,24 @@ class ARIABridge: except Exception as e: logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e) + async def _persist_speed_change(self, speed: float) -> None: + """Schreibt nur den xttsSpeed-Eintrag in voice_config.json — der + Rest bleibt unangetastet. Wird gerufen wenn App per chat-Event + einen neuen Speed mitschickt (kein config-Broadcast).""" + try: + path = "/shared/config/voice_config.json" + data: dict = {} + if os.path.exists(path): + with open(path) as f: + data = json.load(f) or {} + data["xttsSpeed"] = speed + os.makedirs("/shared/config", exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + logger.info("[speed] Persistiert: %.2fx", speed) + except Exception as exc: + logger.warning("[speed] Persistierung fehlgeschlagen: %s", exc) + def _fetch_active_session(self) -> None: """Holt die aktive Session vom Diagnostic-Endpoint.""" try: @@ -1732,11 +1778,23 @@ class ARIABridge: self._next_voice_override = voice_override or None logger.info("[rvs] Voice fuer Antworten: %s", self._next_voice_override or "(Default)") - # Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet) + # Speed-Override (TTS-Wiedergabegeschwindigkeit, pro Geraet) — + # plus persistente Spiegelung damit der Wert nach Bridge-Restart + # erhalten bleibt und Diagnostic-Chats / Trigger-Replies den + # zuletzt von der App gesetzten Speed bekommen. if "speed" in payload: try: speed = float(payload.get("speed", 0) or 0) - self._next_speed_override = speed if 0.1 <= speed <= 5.0 else None + if 0.1 <= speed <= 5.0: + self._next_speed_override = speed + # Persistieren wenn der Wert sich gegenueber dem + # gespeicherten geaendert hat — vermeidet voice_config.json + # auf jeder Nachricht zu schreiben. + if speed != getattr(self, "_persistent_xtts_speed", None): + self._persistent_xtts_speed = speed + asyncio.create_task(self._persist_speed_change(speed)) + else: + self._next_speed_override = None except (TypeError, ValueError): self._next_speed_override = None if text: @@ -1865,6 +1923,15 @@ class ARIABridge: self.xtts_voice = payload["xttsVoice"] logger.info("[rvs] XTTS-Stimme: %s", self.xtts_voice or "default") changed = True + if "xttsSpeed" in payload: + try: + new_speed = float(payload["xttsSpeed"]) + if 0.1 <= new_speed <= 5.0: + self._persistent_xtts_speed = new_speed + logger.info("[rvs] XTTS-Speed (persistent): %.2fx", new_speed) + changed = True + except (TypeError, ValueError): + pass if "whisperModel" in payload: new_model = payload["whisperModel"] allowed = {"tiny", "base", "small", "medium", "large-v3"} @@ -1900,6 +1967,8 @@ class ARIABridge: "xttsVoice": getattr(self, "xtts_voice", ""), "whisperModel": self.stt_engine.model_size, } + if getattr(self, "_persistent_xtts_speed", None) is not None: + config_data["xttsSpeed"] = self._persistent_xtts_speed config_data.update(getattr(self, "_f5tts_config", {})) config_data.update(getattr(self, "_flux_config", {})) with open("/shared/config/voice_config.json", "w") as f: