/** * 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, SkillConfigField, SkillVersion } 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); // P3: Skill-Config (statische Werte je Skill, z.B. API-Keys) const [cfgSchema, setCfgSchema] = useState([]); const [cfgValues, setCfgValues] = useState>({}); const [cfgDraft, setCfgDraft] = useState>({}); const [cfgSaving, setCfgSaving] = useState(false); // P4: Versionen + Rollback const [versions, setVersions] = useState([]); const [versionsLoading, setVersionsLoading] = useState(false); const args = Array.isArray(skill.args) ? skill.args : []; // Config + Versionen beim Mount laden useEffect(() => { brainApi.getSkillConfig(skill.name) .then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); }) .catch(() => {}); setVersionsLoading(true); brainApi.listSkillVersions(skill.name) .then(setVersions) .catch(() => setVersions([])) .finally(() => setVersionsLoading(false)); }, [skill.name]); 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))); }, }, ], ); }; const saveConfig = () => { // secret-Felder die als '***SET***' angezeigt sind und vom User NICHT // angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur // explizit getippte Werte; alles andere uebernehmen wir aus cfgValues. const next: Record = { ...cfgValues }; for (const f of cfgSchema) { const draft = cfgDraft[f.name]; const isSecret = f.secret || f.type === 'password'; if (draft === undefined) continue; if (isSecret && draft === '') continue; // leer = unveraendert if (draft === '') { delete next[f.name]; continue; } if (f.type === 'number') { const n = Number(draft); next[f.name] = isNaN(n) ? draft : n; } else if (f.type === 'boolean') { next[f.name] = draft === 'true' || draft === '1'; } else { next[f.name] = draft; } } // Maskierte Werte (***SET***) niemals zurueckschreiben for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k]; setCfgSaving(true); brainApi.setSkillConfig(skill.name, next) .then(() => { // frisch laden um neuen masked-State zu zeigen return brainApi.getSkillConfig(skill.name); }) .then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); }) .catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e))) .finally(() => setCfgSaving(false)); }; const reloadVersions = () => { setVersionsLoading(true); brainApi.listSkillVersions(skill.name) .then(setVersions) .catch(() => {}) .finally(() => setVersionsLoading(false)); }; const doRollback = (versionId: string) => { Alert.alert( 'Rollback?', `Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Rollback', style: 'destructive', onPress: () => { brainApi.rollbackSkill(skill.name, versionId) .then(r => { Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`); reloadVersions(); onReload(); }) .catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e))); }, }, ], ); }; const removeVersion = (versionId: string) => { Alert.alert( 'Version loeschen?', `${versionId} dauerhaft entfernen?`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Loeschen', style: 'destructive', onPress: () => { brainApi.deleteSkillVersion(skill.name, versionId) .then(reloadVersions) .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} {/* Config-Schema-Form (P3) */} {cfgSchema.length > 0 ? ( <> ⚙ Konfiguration {cfgSchema.map((f) => { const isSecret = f.secret || f.type === 'password'; const cur = cfgValues[f.name]; const isSet = isSecret && cur === '***SET***'; const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert' : (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string')); const valStr = cfgDraft[f.name] !== undefined ? cfgDraft[f.name] : (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : '')); if (f.type === 'boolean') { const bv = cfgDraft[f.name] !== undefined ? (cfgDraft[f.name] === 'true') : (cur === true || cur === 'true'); return ( setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))} trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" /> {f.label || f.name} {f.description ? {f.description} : null} ); } return ( {f.label || f.name}{isSecret ? ' 🔒' : ''} {f.description ? — {f.description} : null} setCfgDraft(p => ({...p, [f.name]: v}))} placeholder={placeholder} placeholderTextColor="#444460" autoCapitalize="none" autoCorrect={false} secureTextEntry={isSecret} keyboardType={f.type === 'number' ? 'numeric' : 'default'} /> ); })} {cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'} ) : null} {/* Versionen (P4) */} {versions.length > 0 ? ( <> 📦 Versionen ({versions.length}) {versions.map(v => ( {v.version_id} {v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'} {v.summary ? {v.summary} : null} doRollback(v.version_id)} style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}> removeVersion(v.version_id)} style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}> 🗑 ))} ) : versionsLoading ? ( ) : 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;