feat(skills): P3 config_schema + P4 Versionierung mit Rollback

P3 — Skill-Configuration
- aria-brain/skills.py: SKILL_CONFIGS_FILE (/shared/config/skill_configs.json)
  als zentrale Werte-Persistenz. _normalize_config_schema validiert die
  Schema-Felder (name/type/label/secret/description/default), CFG_<UPPER_NAME>
  ENV beim run_skill. create_skill + update_skill akzeptieren config_schema.
- agent.py: skill_set_config Brain-Tool fuer ARIA. skill_create/update um
  config_schema-Property erweitert.
- main.py: GET/POST /skills/{name}/config — secret-Werte in Antwort gemaskt.

P4 — Versionierung mit Rollback
- aria-brain/skills.py: archive_current_version archiviert nach
  versions/v_<ts>/ (ohne venv/logs). update_skill ruft das automatisch auf
  bevor strukturelle Aenderungen passieren. list_skill_versions,
  rollback_skill (mit Safety-Snapshot + automatischem venv-Rebuild),
  delete_skill_version.
- agent.py: skill_list_versions, skill_rollback Brain-Tools.
- main.py: GET /skills/{name}/versions, POST /skills/{name}/rollback,
  DELETE /skills/{name}/versions/{version_id}.

UI
- diagnostic/index.html: Skill-Detail um Config-Form (typ-spezifisch,
  Secrets als password-Input mit ***SET***-Hinweis) und Versions-Liste
  mit Rollback-/Delete-Button.
- android SkillBrowser: SkillDetailModal laedt config_schema + versions
  on-mount. Config-Form (TextInput + Switch fuer boolean), Versionen mit
  Rollback-Confirm. brainApi um SkillConfigField/SkillVersion +
  getSkillConfig/setSkillConfig/listSkillVersions/rollbackSkill/
  deleteSkillVersion erweitert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:52:46 +02:00
parent 1a72f27861
commit 8359500476
6 changed files with 884 additions and 2 deletions
+188 -1
View File
@@ -24,7 +24,7 @@ import {
View,
} from 'react-native';
import brainApi, { Skill } from '../services/brainApi';
import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
const COL_ACTIVE = '#34C759';
const COL_INACTIVE = '#555570';
@@ -177,8 +177,30 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
const [logs, setLogs] = useState<any[] | null>(null);
const [loadingLogs, setLoadingLogs] = useState(false);
// P3: Skill-Config (statische Werte je Skill, z.B. API-Keys)
const [cfgSchema, setCfgSchema] = useState<SkillConfigField[]>([]);
const [cfgValues, setCfgValues] = useState<Record<string, any>>({});
const [cfgDraft, setCfgDraft] = useState<Record<string, string>>({});
const [cfgSaving, setCfgSaving] = useState(false);
// P4: Versionen + Rollback
const [versions, setVersions] = useState<SkillVersion[]>([]);
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 }));
@@ -225,6 +247,85 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
);
};
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<string, any> = { ...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 (
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
<View style={s.modal}>
@@ -274,6 +375,92 @@ const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) =
</>
) : null}
{/* Config-Schema-Form (P3) */}
{cfgSchema.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}> Konfiguration</Text>
{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 (
<View key={f.name} style={{marginBottom: 10, flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Switch value={bv} onValueChange={(v) => setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))}
trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" />
<View style={{flex: 1}}>
<Text style={{color: '#E0E0F0', fontSize: 13}}>{f.label || f.name}</Text>
{f.description ? <Text style={{color: '#555570', fontSize: 11}}>{f.description}</Text> : null}
</View>
</View>
);
}
return (
<View key={f.name} style={{marginBottom: 10}}>
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
{f.label || f.name}{isSecret ? ' 🔒' : ''}
{f.description ? <Text style={{color: '#555570'}}> {f.description}</Text> : null}
</Text>
<TextInput
style={s.input}
value={valStr}
onChangeText={(v) => setCfgDraft(p => ({...p, [f.name]: v}))}
placeholder={placeholder}
placeholderTextColor="#444460"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={isSecret}
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
/>
</View>
);
})}
<TouchableOpacity
style={[s.btn, {backgroundColor: '#1A1A2E', borderColor: COL_ACTIVE, marginTop: 4}]}
onPress={saveConfig}
disabled={cfgSaving}
>
<Text style={{color: COL_ACTIVE, textAlign: 'center', fontWeight: '700'}}>
{cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'}
</Text>
</TouchableOpacity>
</>
) : null}
{/* Versionen (P4) */}
{versions.length > 0 ? (
<>
<Text style={[s.label, {marginTop: 18}]}>📦 Versionen ({versions.length})</Text>
{versions.map(v => (
<View key={v.version_id} style={[s.metaBox, {marginTop: 6, flexDirection: 'row', alignItems: 'center', gap: 6}]}>
<View style={{flex: 1}}>
<Text style={[s.meta, {fontFamily: 'monospace', color: '#E0E0F0'}]}>{v.version_id}</Text>
<Text style={s.meta}>{v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'}</Text>
{v.summary ? <Text style={[s.meta, {fontStyle: 'italic'}]} numberOfLines={2}>{v.summary}</Text> : null}
</View>
<TouchableOpacity onPress={() => doRollback(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}>
<Text style={{color: COL_ARIA, fontSize: 12}}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => removeVersion(v.version_id)}
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}>
<Text style={{color: '#FF6B6B', fontSize: 12}}>🗑</Text>
</TouchableOpacity>
</View>
))}
</>
) : versionsLoading ? (
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
) : null}
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
<TouchableOpacity
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}