feat(app+brain): App-Bugfixes + Skill-Mgmt-Tools + Voice-Speed persistent + Skill-Browser
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) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import SettingsScreen from './src/screens/SettingsScreen';
|
|||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||||
|
import gpsTrackingService from './src/services/gpsTracking';
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -99,6 +100,13 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
initBackground();
|
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
|
// Beim Beenden: Verbindung sauber trennen
|
||||||
return () => {
|
return () => {
|
||||||
rvs.disconnect();
|
rvs.disconnect();
|
||||||
|
|||||||
@@ -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<Skill[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [detail, setDetail] = useState<Skill | null>(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 (
|
||||||
|
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
|
||||||
|
<View style={{flex: 1, marginRight: 8}}>
|
||||||
|
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||||
|
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
|
||||||
|
{item.setup_error ? (
|
||||||
|
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
|
||||||
|
⚠ Setup-Fehler: {item.setup_error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||||
|
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={item.active}
|
||||||
|
onValueChange={() => toggleActive(item)}
|
||||||
|
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||||
|
thumbColor="#E0E0F0"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{flex: 1}}>
|
||||||
|
<View style={s.toolbar}>
|
||||||
|
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={f}
|
||||||
|
style={[s.chip, filter === f && s.chipActive]}
|
||||||
|
onPress={() => setFilter(f)}
|
||||||
|
>
|
||||||
|
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||||
|
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||||
|
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||||
|
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={visible}
|
||||||
|
keyExtractor={s => s.name}
|
||||||
|
renderItem={renderItem}
|
||||||
|
nestedScrollEnabled={true}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||||
|
{items.length === 0
|
||||||
|
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
|
||||||
|
: '(keine Treffer für diesen Filter)'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{paddingBottom: 20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<SkillDetailModal
|
||||||
|
skill={detail}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
|
||||||
|
|
||||||
|
interface DetailProps {
|
||||||
|
skill: Skill;
|
||||||
|
onClose: () => void;
|
||||||
|
onReload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
|
||||||
|
const [argValues, setArgValues] = useState<Record<string, string>>({});
|
||||||
|
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<any[] | null>(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<string, any> = {};
|
||||||
|
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 (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||||
|
<View style={s.modal}>
|
||||||
|
<View style={s.modalHeader}>
|
||||||
|
<Text style={s.modalTitle} numberOfLines={1}>{skill.name}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||||
|
<Text style={s.label}>Beschreibung</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
|
||||||
|
|
||||||
|
<View style={s.metaBox}>
|
||||||
|
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
|
||||||
|
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
|
||||||
|
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
|
||||||
|
{skill.setup_error ? (
|
||||||
|
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
|
||||||
|
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Args-Inputs */}
|
||||||
|
{args.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
|
||||||
|
{args.map((a: any) => (
|
||||||
|
<View key={a.name} style={{marginBottom: 10}}>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||||||
|
{a.name}{a.required ? ' *' : ''} {a.description ? `— ${a.description}` : ''}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={s.input}
|
||||||
|
value={argValues[a.name] || ''}
|
||||||
|
onChangeText={(v) => setArg(a.name, v)}
|
||||||
|
placeholder={a.type || 'string'}
|
||||||
|
placeholderTextColor="#444460"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
|
||||||
|
onPress={run}
|
||||||
|
disabled={!skill.active || running}
|
||||||
|
>
|
||||||
|
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
|
||||||
|
{running ? 'läuft...' : '▶ Ausführen'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
|
||||||
|
onPress={loadLogs}
|
||||||
|
>
|
||||||
|
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!skill.active ? (
|
||||||
|
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
|
||||||
|
Skill ist deaktiviert — toggle in der Liste zum Aktivieren.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Run-Result */}
|
||||||
|
{runResult ? (
|
||||||
|
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||||
|
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
|
||||||
|
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
|
||||||
|
</Text>
|
||||||
|
{runResult.stdout ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{runResult.stderr ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
{loadingLogs ? (
|
||||||
|
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||||||
|
) : logs ? (
|
||||||
|
<View style={{marginTop: 14}}>
|
||||||
|
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
|
||||||
|
) : logs.map((log, idx) => (
|
||||||
|
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||||
|
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||||
|
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
|
||||||
|
</Text>
|
||||||
|
{log.stdout ? (
|
||||||
|
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
|
||||||
|
{String(log.stdout).slice(0, 300)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{height: 30}} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={s.modalFooter}>
|
||||||
|
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
|
||||||
|
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={{flex: 1}} />
|
||||||
|
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
|
||||||
|
<Text style={{color: '#8888AA'}}>Schliessen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
AppState,
|
AppState,
|
||||||
NativeModules,
|
NativeModules,
|
||||||
Alert,
|
Alert,
|
||||||
|
Pressable,
|
||||||
|
Share,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import RNFS from 'react-native-fs';
|
import RNFS from 'react-native-fs';
|
||||||
@@ -1987,7 +1989,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Pressable
|
||||||
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||||
onLayout={e => {
|
onLayout={e => {
|
||||||
// Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
|
// 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.
|
// unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
|
||||||
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
itemHeights.current.set(item.id, e.nativeEvent.layout.height);
|
||||||
}}
|
}}
|
||||||
|
onLongPress={() => openBubbleActions(item)}
|
||||||
|
delayLongPress={500}
|
||||||
|
android_ripple={null}
|
||||||
>
|
>
|
||||||
{/* Anhang-Vorschau */}
|
{/* Anhang-Vorschau */}
|
||||||
{item.attachments?.map((att, idx) => (
|
{item.attachments?.map((att, idx) => (
|
||||||
@@ -2125,6 +2130,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
<View style={styles.statusRow}>
|
<View style={styles.statusRow}>
|
||||||
<Text style={styles.timestamp}>{time}</Text>
|
<Text style={styles.timestamp}>{time}</Text>
|
||||||
|
{item.text.length > 0 ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
hitSlop={{top:6,bottom:6,left:6,right:6}}
|
||||||
|
onPress={() => openBubbleActions(item)}
|
||||||
|
accessibilityLabel="Aktionen"
|
||||||
|
>
|
||||||
|
<Text style={styles.bubbleCopyIcon}>{'⎘'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
{isUser && item.deliveryStatus ? (
|
{isUser && item.deliveryStatus ? (
|
||||||
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -2148,7 +2162,58 @@ const ChatScreen: React.FC = () => {
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<string>();
|
||||||
|
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,
|
fontSize: 12,
|
||||||
color: '#FF6B6B',
|
color: '#FF6B6B',
|
||||||
},
|
},
|
||||||
|
bubbleCopyIcon: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#8888AA',
|
||||||
|
marginLeft: 6,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
fullscreenOverlay: {
|
fullscreenOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.95)',
|
backgroundColor: 'rgba(0,0,0,0.95)',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import gpsTrackingService from '../services/gpsTracking';
|
|||||||
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||||
import MemoryBrowser from '../components/MemoryBrowser';
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
import TriggerBrowser from '../components/TriggerBrowser';
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
|
import SkillBrowser from '../components/SkillBrowser';
|
||||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||||
import {
|
import {
|
||||||
isWakeReadySoundEnabled,
|
isWakeReadySoundEnabled,
|
||||||
@@ -106,6 +107,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'files', icon: '📂', label: 'Dateien', desc: 'ARIA- und User-Dateien — anzeigen, löschen' },
|
{ 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: '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: '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: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -928,7 +930,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers'}
|
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills'}
|
||||||
>
|
>
|
||||||
|
|
||||||
{currentSection === null && (
|
{currentSection === null && (
|
||||||
@@ -1809,6 +1811,19 @@ const SettingsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* === Skills === */}
|
||||||
|
{currentSection === 'skills' && (<>
|
||||||
|
<Text style={styles.sectionTitle}>Skills</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||||
|
<SkillBrowser />
|
||||||
|
</View>
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* === Logs === */}
|
{/* === Logs === */}
|
||||||
{currentSection === 'protocol' && (<>
|
{currentSection === 'protocol' && (<>
|
||||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ export interface Memory {
|
|||||||
attachments?: MemoryAttachment[];
|
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. */
|
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||||
export interface Trigger {
|
export interface Trigger {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -236,9 +254,12 @@ export const brainApi = {
|
|||||||
|
|
||||||
// ── Triggers ────────────────────────────────────────────────────────
|
// ── 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<Trigger[]> {
|
listTriggers(): Promise<Trigger[]> {
|
||||||
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, ...). */
|
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||||
@@ -301,6 +322,60 @@ export const brainApi = {
|
|||||||
timeoutMs: 15000,
|
timeoutMs: 15000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Skills ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Liste aller Skills (aktive + inaktive). Brain returnt {skills: [...]}. */
|
||||||
|
listSkills(): Promise<Skill[]> {
|
||||||
|
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<Skill> {
|
||||||
|
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<string, any> = {}): 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<Skill> {
|
||||||
|
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<any[]> {
|
||||||
|
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default brainApi;
|
export default brainApi;
|
||||||
|
|||||||
@@ -123,6 +123,67 @@ META_TOOLS = [
|
|||||||
"parameters": {"type": "object", "properties": {}},
|
"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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -746,6 +807,46 @@ class Agent:
|
|||||||
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
|
||||||
for s in items
|
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_"):
|
if name.startswith("run_"):
|
||||||
skill_name = name[len("run_"):]
|
skill_name = name[len("run_"):]
|
||||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||||
|
|||||||
@@ -194,14 +194,59 @@ def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def update_skill(name: str, patch: dict) -> dict:
|
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)
|
manifest = read_manifest(name)
|
||||||
if manifest is None:
|
if manifest is None:
|
||||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||||
|
d = _skill_dir(name)
|
||||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
||||||
for k, v in patch.items():
|
for k, v in patch.items():
|
||||||
if k in allowed:
|
if k in allowed:
|
||||||
manifest[k] = v
|
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)
|
write_manifest(name, manifest)
|
||||||
|
logger.info("Skill aktualisiert: %s (keys=%s)", name, sorted(patch.keys()))
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+72
-3
@@ -519,6 +519,10 @@ class ARIABridge:
|
|||||||
self.xtts_voice = ""
|
self.xtts_voice = ""
|
||||||
self._f5tts_config: dict = {}
|
self._f5tts_config: dict = {}
|
||||||
self._flux_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 = {}
|
vc: dict = {}
|
||||||
# Gespeicherte Voice-Config laden
|
# Gespeicherte Voice-Config laden
|
||||||
try:
|
try:
|
||||||
@@ -528,6 +532,19 @@ class ARIABridge:
|
|||||||
vc = json.load(f)
|
vc = json.load(f)
|
||||||
self.tts_enabled = vc.get("ttsEnabled", True)
|
self.tts_enabled = vc.get("ttsEnabled", True)
|
||||||
self.xtts_voice = vc.get("xttsVoice", "")
|
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,
|
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
|
||||||
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
# damit die f5tts-bridge auf der Gamebox die Settings auch nach
|
||||||
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
|
# 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
|
# TTS-Call wieder die alte Default-Stimme. Der Override bleibt gueltig bis
|
||||||
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
|
# zum naechsten chat-Event, wo er entweder ueberschrieben oder geloescht wird.
|
||||||
xtts_voice = self._next_voice_override or getattr(self, 'xtts_voice', '')
|
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
|
tts_text = tts_text_preview or text
|
||||||
if not tts_text:
|
if not tts_text:
|
||||||
@@ -1274,6 +1300,8 @@ class ARIABridge:
|
|||||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||||
"whisperModel": self.stt_engine.model_size,
|
"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, "_f5tts_config", {}) or {})
|
||||||
payload.update(getattr(self, "_flux_config", {}) or {})
|
payload.update(getattr(self, "_flux_config", {}) or {})
|
||||||
await self._send_to_rvs({
|
await self._send_to_rvs({
|
||||||
@@ -1285,6 +1313,24 @@ class ARIABridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", 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:
|
def _fetch_active_session(self) -> None:
|
||||||
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
"""Holt die aktive Session vom Diagnostic-Endpoint."""
|
||||||
try:
|
try:
|
||||||
@@ -1732,11 +1778,23 @@ class ARIABridge:
|
|||||||
self._next_voice_override = voice_override or None
|
self._next_voice_override = voice_override or None
|
||||||
logger.info("[rvs] Voice fuer Antworten: %s",
|
logger.info("[rvs] Voice fuer Antworten: %s",
|
||||||
self._next_voice_override or "(Default)")
|
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:
|
if "speed" in payload:
|
||||||
try:
|
try:
|
||||||
speed = float(payload.get("speed", 0) or 0)
|
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):
|
except (TypeError, ValueError):
|
||||||
self._next_speed_override = None
|
self._next_speed_override = None
|
||||||
if text:
|
if text:
|
||||||
@@ -1865,6 +1923,15 @@ class ARIABridge:
|
|||||||
self.xtts_voice = payload["xttsVoice"]
|
self.xtts_voice = payload["xttsVoice"]
|
||||||
logger.info("[rvs] XTTS-Stimme: %s", self.xtts_voice or "default")
|
logger.info("[rvs] XTTS-Stimme: %s", self.xtts_voice or "default")
|
||||||
changed = True
|
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:
|
if "whisperModel" in payload:
|
||||||
new_model = payload["whisperModel"]
|
new_model = payload["whisperModel"]
|
||||||
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
allowed = {"tiny", "base", "small", "medium", "large-v3"}
|
||||||
@@ -1900,6 +1967,8 @@ class ARIABridge:
|
|||||||
"xttsVoice": getattr(self, "xtts_voice", ""),
|
"xttsVoice": getattr(self, "xtts_voice", ""),
|
||||||
"whisperModel": self.stt_engine.model_size,
|
"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, "_f5tts_config", {}))
|
||||||
config_data.update(getattr(self, "_flux_config", {}))
|
config_data.update(getattr(self, "_flux_config", {}))
|
||||||
with open("/shared/config/voice_config.json", "w") as f:
|
with open("/shared/config/voice_config.json", "w") as f:
|
||||||
|
|||||||
Reference in New Issue
Block a user