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 { 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();
|
||||
|
||||
@@ -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,
|
||||
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 (
|
||||
<View
|
||||
<Pressable
|
||||
style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
|
||||
onLayout={e => {
|
||||
// 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}
|
||||
<View style={styles.statusRow}>
|
||||
<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 ? (
|
||||
item.deliveryStatus === 'failed' && item.clientMsgId ? (
|
||||
<TouchableOpacity
|
||||
@@ -2148,7 +2162,58 @@ const ChatScreen: React.FC = () => {
|
||||
)
|
||||
) : null}
|
||||
</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,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
bubbleCopyIcon: {
|
||||
fontSize: 13,
|
||||
color: '#8888AA',
|
||||
marginLeft: 6,
|
||||
opacity: 0.7,
|
||||
},
|
||||
fullscreenOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.95)',
|
||||
|
||||
@@ -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 = () => {
|
||||
</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 === */}
|
||||
{currentSection === 'protocol' && (<>
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
|
||||
@@ -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<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, ...). */
|
||||
@@ -301,6 +322,60 @@ export const brainApi = {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user