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:
@@ -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}]}
|
||||
|
||||
Reference in New Issue
Block a user