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}]}
|
||||
|
||||
@@ -158,6 +158,26 @@ export interface Skill {
|
||||
version?: string;
|
||||
author?: string; // "aria" | "stefan"
|
||||
setup_error?: string;
|
||||
// P3: konfigurierbare Werte (API-Keys, IDs etc.) — Stefan setzt sie hier,
|
||||
// Skill bekommt sie als CFG_<NAME> ENV. Werte selbst kommen via /config.
|
||||
config_schema?: SkillConfigField[];
|
||||
// P4: Versions-Historie. Detail-Liste kommt via /versions.
|
||||
version_history?: { version_id: string; archived_at?: string; summary?: string }[];
|
||||
}
|
||||
|
||||
export interface SkillConfigField {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'password';
|
||||
label?: string;
|
||||
secret?: boolean;
|
||||
description?: string;
|
||||
default?: any;
|
||||
}
|
||||
|
||||
export interface SkillVersion {
|
||||
version_id: string;
|
||||
archived_at?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||
@@ -395,7 +415,46 @@ export const brainApi = {
|
||||
|
||||
/** Letzte Run-Logs eines Skills. */
|
||||
getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`)
|
||||
.then((r: any) => Array.isArray(r) ? r : (r?.logs || []));
|
||||
},
|
||||
|
||||
/** P3: Config-Schema + aktuelle Werte (secret-Felder gemaskt mit '***SET***'). */
|
||||
getSkillConfig(name: string): Promise<{ schema: SkillConfigField[]; values: Record<string, any> }> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/config`)
|
||||
.then((r: any) => ({ schema: r?.schema || [], values: r?.values || {} }));
|
||||
},
|
||||
|
||||
/** P3: Config-Werte komplett ueberschreiben. Werte greifen ab dem naechsten Run. */
|
||||
setSkillConfig(name: string, values: Record<string, any>): Promise<{ ok: boolean; values: Record<string, any> }> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/config`, {
|
||||
method: 'POST',
|
||||
body: { values },
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
},
|
||||
|
||||
/** P4: Liste archivierter Versionen, neueste zuerst. */
|
||||
listSkillVersions(name: string): Promise<SkillVersion[]> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/versions`)
|
||||
.then((r: any) => r?.versions || []);
|
||||
},
|
||||
|
||||
/** P4: Rollback auf eine fruehere Version. Aktueller Stand wird automatisch gesichert. */
|
||||
rollbackSkill(name: string, versionId: string): Promise<{ ok: boolean; rolled_back_to: string; safety_snapshot: string }> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/rollback`, {
|
||||
method: 'POST',
|
||||
body: { version_id: versionId },
|
||||
timeoutMs: 60000, // venv-Rebuild kann dauern
|
||||
});
|
||||
},
|
||||
|
||||
/** P4: Einzelne Version dauerhaft loeschen. */
|
||||
deleteSkillVersion(name: string, versionId: string): Promise<{ ok: boolean; deleted: string }> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/versions/${encodeURIComponent(versionId)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── OAuth ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -171,11 +171,85 @@ META_TOOLS = [
|
||||
},
|
||||
"description": {"type": "string", "description": "Neue Beschreibung (optional)"},
|
||||
"active": {"type": "boolean", "description": "Aktivieren/deaktivieren (optional)"},
|
||||
"config_schema": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": (
|
||||
"Optional neues config_schema fuer den Skill. Liste von "
|
||||
"Feldern [{name, type, label, secret?, description?, default?}]. "
|
||||
"type: string|number|boolean|password (password impliziert secret=true). "
|
||||
"Setzt Stefan in Diagnostic; Skill bekommt CFG_<NAME> ENV."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skill_set_config",
|
||||
"description": (
|
||||
"Setzt Config-Werte fuer einen Skill persistent (z.B. API-Keys, "
|
||||
"User-IDs, Endpoint-URLs). Werte landen als CFG_<UPPER_NAME> ENV "
|
||||
"im naechsten skill_run. Nutze das wenn Stefan dir im Chat einen "
|
||||
"Wert nennt ('mein OpenWeather-Key ist abc123') — schreib den "
|
||||
"NICHT in den Skill-Code, sondern hierher.\n\n"
|
||||
"WICHTIG: values ueberschreibt komplett. Wenn Du nur einen Wert "
|
||||
"aendern willst: erst per Diagnostic-UI oder Skill-Inspect die "
|
||||
"aktuelle Liste ansehen und mit dem neuen Wert ergaenzen."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Skill-Name"},
|
||||
"values": {
|
||||
"type": "object",
|
||||
"description": "Map config-Feldname → Wert. Felder muessen im config_schema deklariert sein.",
|
||||
},
|
||||
},
|
||||
"required": ["name", "values"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skill_list_versions",
|
||||
"description": (
|
||||
"Listet archivierte Versionen eines Skills (jeder skill_update "
|
||||
"legt automatisch eine an). Returns [{version_id, archived_at, "
|
||||
"summary}]. Brauchst Du fuer skill_rollback."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "skill_rollback",
|
||||
"description": (
|
||||
"Stellt eine fruehere Skill-Version wieder her. Vor dem Rollback "
|
||||
"wird der aktuelle Stand automatisch archiviert — du verlierst "
|
||||
"nichts. Nutze das wenn ein skill_update was kaputt gemacht hat "
|
||||
"oder Stefan sagt 'mach den letzten Stand wieder her'. "
|
||||
"version_id bekommst Du aus skill_list_versions."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"version_id": {"type": "string", "description": "Format v_<timestamp>"},
|
||||
},
|
||||
"required": ["name", "version_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -856,6 +930,7 @@ class Agent:
|
||||
readme=arguments.get("readme", ""),
|
||||
args=arguments.get("args", []),
|
||||
pip_packages=arguments.get("pip_packages", []),
|
||||
config_schema=arguments.get("config_schema") or None,
|
||||
author="aria",
|
||||
)
|
||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||
@@ -888,6 +963,8 @@ class Agent:
|
||||
patch[k] = arguments[k]
|
||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||
patch["pip_packages"] = arguments["pip_packages"]
|
||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||
patch["config_schema"] = arguments["config_schema"]
|
||||
if not patch:
|
||||
return "FEHLER: keine Felder zum Update angegeben."
|
||||
try:
|
||||
@@ -918,6 +995,57 @@ class Agent:
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
return f"OK — Skill '{skill_name}' geloescht."
|
||||
if name == "skill_set_config":
|
||||
skill_name = (arguments.get("name") or "").strip()
|
||||
values = arguments.get("values")
|
||||
if not skill_name or not isinstance(values, dict):
|
||||
return "FEHLER: name + values (dict) erforderlich."
|
||||
try:
|
||||
skills_mod.set_skill_config(skill_name, values)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
masked = skills_mod.get_skill_config_masked(skill_name)
|
||||
return (
|
||||
f"OK — Config fuer Skill '{skill_name}' gesetzt. "
|
||||
f"Aktuelle Werte (secrets gemasked): {masked}"
|
||||
)
|
||||
if name == "skill_list_versions":
|
||||
skill_name = (arguments.get("name") or "").strip()
|
||||
if not skill_name:
|
||||
return "FEHLER: name ist Pflicht."
|
||||
versions = skills_mod.list_skill_versions(skill_name)
|
||||
if not versions:
|
||||
return f"Skill '{skill_name}' hat keine archivierten Versionen."
|
||||
lines = [
|
||||
f"- {v.get('version_id')} ({v.get('archived_at','?')}) {v.get('summary','')}"
|
||||
for v in versions
|
||||
]
|
||||
return "Versionen (neueste zuerst):\n" + "\n".join(lines)
|
||||
if name == "skill_rollback":
|
||||
skill_name = (arguments.get("name") or "").strip()
|
||||
version_id = (arguments.get("version_id") or "").strip()
|
||||
if not skill_name or not version_id:
|
||||
return "FEHLER: name + version_id erforderlich."
|
||||
try:
|
||||
res = skills_mod.rollback_skill(skill_name, version_id)
|
||||
except ValueError as exc:
|
||||
return f"FEHLER: {exc}"
|
||||
# Side-Channel-Event als skill_created getarnt — App/Diagnostic
|
||||
# zeigen Rollback dann als sichtbare Aktion an
|
||||
self._pending_events.append({
|
||||
"type": "skill_created",
|
||||
"skill": {
|
||||
"name": skill_name,
|
||||
"description": "(rollback)",
|
||||
"execution": "local-venv",
|
||||
"active": True,
|
||||
"updated": True,
|
||||
},
|
||||
})
|
||||
return (
|
||||
f"OK — Skill '{skill_name}' auf '{version_id}' zurueckgerollt. "
|
||||
f"Sicherheits-Snapshot des vorherigen Stands: {res.get('safety_snapshot')}"
|
||||
)
|
||||
if name.startswith("run_"):
|
||||
skill_name = name[len("run_"):]
|
||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||
|
||||
@@ -757,6 +757,7 @@ class SkillCreate(BaseModel):
|
||||
requires: dict = Field(default_factory=dict)
|
||||
pip_packages: list = Field(default_factory=list)
|
||||
author: str = "stefan"
|
||||
config_schema: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillRun(BaseModel):
|
||||
@@ -769,6 +770,18 @@ class SkillPatch(BaseModel):
|
||||
description: str | None = None
|
||||
active: bool | None = None
|
||||
args: list | None = None
|
||||
entry_code: str | None = None
|
||||
readme: str | None = None
|
||||
pip_packages: list | None = None
|
||||
config_schema: list | None = None
|
||||
|
||||
|
||||
class SkillConfigSet(BaseModel):
|
||||
values: dict
|
||||
|
||||
|
||||
class SkillRollback(BaseModel):
|
||||
version_id: str
|
||||
|
||||
|
||||
@app.get("/skills/list")
|
||||
@@ -798,6 +811,7 @@ def skills_create(body: SkillCreate):
|
||||
requires=body.requires,
|
||||
pip_packages=body.pip_packages,
|
||||
author=body.author,
|
||||
config_schema=body.config_schema,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
@@ -834,6 +848,57 @@ def skills_logs(name: str, limit: int = 50):
|
||||
return {"logs": skills_mod.list_logs(name, limit=limit)}
|
||||
|
||||
|
||||
# ── Skill-Configs (P3): statische Werte (API-Keys etc.) je Skill ───
|
||||
|
||||
@app.get("/skills/{name}/config")
|
||||
def skills_config_get(name: str):
|
||||
"""Liefert config_schema + aktuelle Werte (secret-Felder gemaskt mit
|
||||
'***SET***')."""
|
||||
manifest = skills_mod.read_manifest(name)
|
||||
if manifest is None:
|
||||
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
|
||||
return {
|
||||
"schema": manifest.get("config_schema") or [],
|
||||
"values": skills_mod.get_skill_config_masked(name),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/skills/{name}/config")
|
||||
def skills_config_set(name: str, body: SkillConfigSet):
|
||||
"""Setzt Config-Werte (komplett ueberschreibend). Werte greifen ab dem
|
||||
naechsten skill_run. Secret-Felder werden in der Antwort gemaskt."""
|
||||
manifest = skills_mod.read_manifest(name)
|
||||
if manifest is None:
|
||||
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
|
||||
skills_mod.set_skill_config(name, body.values)
|
||||
return {"ok": True, "values": skills_mod.get_skill_config_masked(name)}
|
||||
|
||||
|
||||
# ── Skill-Versions (P4): rollback ──────────────────────────────────
|
||||
|
||||
@app.get("/skills/{name}/versions")
|
||||
def skills_versions_list(name: str):
|
||||
if skills_mod.read_manifest(name) is None:
|
||||
raise HTTPException(404, f"Skill '{name}' nicht gefunden")
|
||||
return {"versions": skills_mod.list_skill_versions(name)}
|
||||
|
||||
|
||||
@app.post("/skills/{name}/rollback")
|
||||
def skills_rollback(name: str, body: SkillRollback):
|
||||
try:
|
||||
return skills_mod.rollback_skill(name, body.version_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
|
||||
|
||||
@app.delete("/skills/{name}/versions/{version_id}")
|
||||
def skills_versions_delete(name: str, version_id: str):
|
||||
try:
|
||||
return skills_mod.delete_skill_version(name, version_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
|
||||
|
||||
@app.get("/skills/{name}/export")
|
||||
def skills_export(name: str):
|
||||
try:
|
||||
|
||||
@@ -47,6 +47,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SKILLS_DIR = Path(os.environ.get("SKILLS_DIR", "/data/skills"))
|
||||
SHARED_UPLOADS = Path("/shared/uploads")
|
||||
SKILL_CONFIGS_FILE = Path(os.environ.get("SKILL_CONFIGS_FILE", "/shared/config/skill_configs.json"))
|
||||
|
||||
# Beim Archivieren in versions/ ausgenommen (gross, regenerierbar, sind keine Sources)
|
||||
_VERSION_SKIP = {"venv", "logs", "versions", "__pycache__"}
|
||||
|
||||
VALID_EXECUTIONS = {"local-venv", "local-bin", "bash"}
|
||||
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
|
||||
@@ -159,6 +163,7 @@ def create_skill(
|
||||
requires: Optional[dict] = None,
|
||||
pip_packages: Optional[list[str]] = None,
|
||||
author: str = "aria",
|
||||
config_schema: Optional[list] = None,
|
||||
) -> dict:
|
||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||
|
||||
@@ -207,6 +212,8 @@ def create_skill(
|
||||
"use_count": 0,
|
||||
"version": "1.0",
|
||||
"author": author,
|
||||
"config_schema": _normalize_config_schema(config_schema),
|
||||
"version_history": [],
|
||||
}
|
||||
write_manifest(name, manifest)
|
||||
|
||||
@@ -225,6 +232,35 @@ def create_skill(
|
||||
return manifest
|
||||
|
||||
|
||||
def _normalize_config_schema(schema: Optional[list]) -> list:
|
||||
"""Filter + Normalisiert das config_schema. Erwartet Liste von Dicts mit
|
||||
Pflichtfeld 'name'. Optional: label, type (string|number|boolean|password),
|
||||
secret (bool), default, description."""
|
||||
if not schema:
|
||||
return []
|
||||
out = []
|
||||
for f in schema:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
fname = (f.get("name") or "").strip()
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]{0,40}$", fname):
|
||||
continue
|
||||
ftype = (f.get("type") or "string").lower()
|
||||
if ftype not in ("string", "number", "boolean", "password"):
|
||||
ftype = "string"
|
||||
# password impliziert secret=True
|
||||
secret = bool(f.get("secret")) or ftype == "password"
|
||||
out.append({
|
||||
"name": fname,
|
||||
"type": ftype,
|
||||
"label": (f.get("label") or fname),
|
||||
"secret": secret,
|
||||
"description": (f.get("description") or "")[:300],
|
||||
"default": f.get("default"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _setup_venv(skill_dir: Path, pip_packages: list[str]) -> None:
|
||||
venv = skill_dir / "venv"
|
||||
logger.info("venv erstellen: %s", venv)
|
||||
@@ -247,10 +283,30 @@ def update_skill(name: str, patch: dict) -> dict:
|
||||
if manifest is None:
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
d = _skill_dir(name)
|
||||
|
||||
# Auto-Archive: wenn strukturelle Aenderung (Code/README/Deps/Schema), erst
|
||||
# snapshot machen. So kann jeder skill_update zurueckgerollt werden.
|
||||
structural = any(k in patch for k in ("entry_code", "readme", "pip_packages",
|
||||
"config_schema", "args"))
|
||||
if structural:
|
||||
try:
|
||||
archive_current_version(
|
||||
name,
|
||||
summary=patch.get("_change_summary") or ", ".join(
|
||||
sorted(k for k in patch.keys() if k != "_change_summary")
|
||||
)[:200],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("update_skill: Auto-Archive %s fehlgeschlagen: %s", name, exc)
|
||||
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||
manifest = read_manifest(name) or manifest
|
||||
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
manifest[k] = v
|
||||
if "config_schema" in patch:
|
||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||
|
||||
# Code austauschen
|
||||
if "entry_code" in patch and patch["entry_code"]:
|
||||
@@ -296,9 +352,230 @@ def delete_skill(name: str) -> None:
|
||||
if not d.exists():
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
shutil.rmtree(d)
|
||||
# Configs auch raeumen — sonst Karteileiche in skill_configs.json
|
||||
try:
|
||||
all_cfg = _load_all_skill_configs()
|
||||
if name in all_cfg:
|
||||
all_cfg.pop(name)
|
||||
_save_all_skill_configs(all_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Skill geloescht: %s", name)
|
||||
|
||||
|
||||
# ─── Skill-Configs (statische Werte je Skill — API-Keys, IDs etc.) ──
|
||||
# Werte liegen zentral in /shared/config/skill_configs.json damit Stefan
|
||||
# sie im Diagnostic-UI editieren kann. Skill bekommt sie zur Laufzeit
|
||||
# als ENV `CFG_<UPPER_NAME>` — kein hardcoden im Code noetig.
|
||||
|
||||
def _load_all_skill_configs() -> dict:
|
||||
if not SKILL_CONFIGS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(SKILL_CONFIGS_FILE.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("skill_configs.json kaputt (%s) — leeres dict", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_all_skill_configs(data: dict) -> None:
|
||||
SKILL_CONFIGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SKILL_CONFIGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
def get_skill_config(name: str) -> dict:
|
||||
"""Liefert die rohen Config-Werte fuer einen Skill (ungemasked).
|
||||
Wird intern beim run_skill genutzt um CFG_<NAME>-Env zu bauen."""
|
||||
return _load_all_skill_configs().get(name, {})
|
||||
|
||||
|
||||
def set_skill_config(name: str, values: dict) -> dict:
|
||||
"""Speichert die Config-Werte fuer einen Skill (komplett ueberschreiben).
|
||||
Werte landen sofort persistent; naechster run_skill nutzt sie."""
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError("values muss ein Dict sein")
|
||||
all_cfg = _load_all_skill_configs()
|
||||
all_cfg[name] = values
|
||||
_save_all_skill_configs(all_cfg)
|
||||
return values
|
||||
|
||||
|
||||
def get_skill_config_masked(name: str) -> dict:
|
||||
"""Wie get_skill_config, aber secret-Felder werden auf '***SET***' maskiert.
|
||||
Schema kommt aus dem skill.json — Felder ohne secret=True werden klar
|
||||
zurueckgegeben. Fuer UI-Anzeige."""
|
||||
manifest = read_manifest(name)
|
||||
schema = (manifest or {}).get("config_schema") or []
|
||||
secret_fields = {f.get("name") for f in schema if f.get("secret")}
|
||||
values = get_skill_config(name)
|
||||
return {k: ("***SET***" if (k in secret_fields and v) else v)
|
||||
for k, v in values.items()}
|
||||
|
||||
|
||||
def _config_env_name(field_name: str) -> str:
|
||||
"""API-Key → CFG_API_KEY. Erlaubt nur a-zA-Z0-9_."""
|
||||
safe = re.sub(r"[^a-zA-Z0-9]", "_", field_name).upper()
|
||||
return f"CFG_{safe}"
|
||||
|
||||
|
||||
# ─── Versionierung (Rollback-fähiges update_skill) ───────────────────
|
||||
# Vor jedem strukturellen update wird der aktuelle Stand nach
|
||||
# versions/v_<ts>/ kopiert (ohne venv/logs/versions). Rollback kopiert
|
||||
# eine Version zurueck — vorher noch ein Auto-Snapshot, damit auch der
|
||||
# Rollback rueckholbar ist.
|
||||
|
||||
def _versions_dir(name: str) -> Path:
|
||||
return _skill_dir(name) / "versions"
|
||||
|
||||
|
||||
def _copytree_skill(src: Path, dst: Path) -> None:
|
||||
"""Kopiert Skill-Sources (alles ausser venv/logs/versions/__pycache__)."""
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for item in src.iterdir():
|
||||
if item.name in _VERSION_SKIP:
|
||||
continue
|
||||
target = dst / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, target, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
|
||||
def archive_current_version(name: str, summary: str = "") -> str:
|
||||
"""Kopiert den aktuellen Skill-Stand nach versions/v_<ts>/. Returnt die
|
||||
version_id. Im Manifest wird `version_history` gepflegt."""
|
||||
d = _skill_dir(name)
|
||||
if not d.exists():
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
ts = int(time.time())
|
||||
version_id = f"v_{ts}"
|
||||
# Kollisionsschutz bei sub-Sekunden-Calls
|
||||
while (_versions_dir(name) / version_id).exists():
|
||||
ts += 1
|
||||
version_id = f"v_{ts}"
|
||||
archive = _versions_dir(name) / version_id
|
||||
_copytree_skill(d, archive)
|
||||
(archive / "_version.json").write_text(json.dumps({
|
||||
"version_id": version_id,
|
||||
"archived_at": _now(),
|
||||
"summary": (summary or "")[:300],
|
||||
}, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
# Manifest-History pflegen (read-back nach _copytree, damit history konsistent)
|
||||
manifest = read_manifest(name)
|
||||
if manifest is not None:
|
||||
hist = list(manifest.get("version_history") or [])
|
||||
hist.append({"version_id": version_id, "archived_at": _now(),
|
||||
"summary": (summary or "")[:300]})
|
||||
# Cap auf 50 Versionen — alte Eintraege wegrotieren (Dateien bleiben aber)
|
||||
manifest["version_history"] = hist[-50:]
|
||||
write_manifest(name, manifest)
|
||||
return version_id
|
||||
|
||||
|
||||
def list_skill_versions(name: str) -> list[dict]:
|
||||
"""Liste aller archivierten Versionen, neueste zuerst."""
|
||||
versions = _versions_dir(name)
|
||||
if not versions.exists():
|
||||
return []
|
||||
out = []
|
||||
for entry in sorted(versions.iterdir(), reverse=True):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
meta = entry / "_version.json"
|
||||
if meta.exists():
|
||||
try:
|
||||
out.append(json.loads(meta.read_text(encoding="utf-8")))
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
out.append({"version_id": entry.name, "archived_at": "", "summary": ""})
|
||||
return out
|
||||
|
||||
|
||||
def rollback_skill(name: str, version_id: str) -> dict:
|
||||
"""Stellt eine archivierte Version wieder her. Vorher wird der aktuelle
|
||||
Stand automatisch als neue Version archiviert ('safety_snapshot') —
|
||||
Rollback ist also nicht destruktiv. venv wird neu aufgebaut wenn
|
||||
requirements.txt vorhanden ist."""
|
||||
d = _skill_dir(name)
|
||||
if not d.exists():
|
||||
raise ValueError(f"Skill '{name}' nicht gefunden")
|
||||
archive = _versions_dir(name) / version_id
|
||||
if not archive.exists() or not archive.is_dir():
|
||||
raise ValueError(f"Version '{version_id}' fuer Skill '{name}' nicht gefunden")
|
||||
|
||||
# 1. Sicherung des aktuellen Stands
|
||||
safety = archive_current_version(name, summary=f"safety-snapshot vor rollback auf {version_id}")
|
||||
|
||||
# 2. Aktuelle Sources loeschen (venv/logs/versions bleiben)
|
||||
for item in d.iterdir():
|
||||
if item.name in _VERSION_SKIP:
|
||||
continue
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item, ignore_errors=True)
|
||||
else:
|
||||
try:
|
||||
item.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# 3. Archive zurueck kopieren (ohne _version.json — das ist Versions-Metadata)
|
||||
for item in archive.iterdir():
|
||||
if item.name == "_version.json":
|
||||
continue
|
||||
target = d / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, target, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
# 4. Manifest-Stempel
|
||||
manifest = read_manifest(name)
|
||||
if manifest is not None:
|
||||
manifest["updated_at"] = _now()
|
||||
manifest["last_rollback"] = {"to": version_id, "safety": safety, "at": _now()}
|
||||
write_manifest(name, manifest)
|
||||
|
||||
# 5. venv-Rebuild bei local-venv
|
||||
req_file = d / "requirements.txt"
|
||||
if (manifest or {}).get("execution") == "local-venv" and req_file.exists():
|
||||
pip_packages = [l.strip() for l in req_file.read_text(encoding="utf-8").splitlines()
|
||||
if l.strip() and not l.strip().startswith("#")]
|
||||
venv = d / "venv"
|
||||
if venv.exists():
|
||||
shutil.rmtree(venv, ignore_errors=True)
|
||||
try:
|
||||
_setup_venv(d, pip_packages)
|
||||
if manifest is not None:
|
||||
manifest.pop("setup_error", None)
|
||||
manifest["active"] = True
|
||||
write_manifest(name, manifest)
|
||||
except Exception as exc:
|
||||
if manifest is not None:
|
||||
manifest["active"] = False
|
||||
manifest["setup_error"] = str(exc)[:500]
|
||||
write_manifest(name, manifest)
|
||||
logger.warning("Rollback %s: venv-Rebuild fehlgeschlagen: %s", name, exc)
|
||||
|
||||
return {"ok": True, "name": name, "rolled_back_to": version_id,
|
||||
"safety_snapshot": safety}
|
||||
|
||||
|
||||
def delete_skill_version(name: str, version_id: str) -> dict:
|
||||
"""Loescht eine einzelne Version aus versions/. Nicht-rueckholbar."""
|
||||
archive = _versions_dir(name) / version_id
|
||||
if not archive.exists():
|
||||
raise ValueError(f"Version '{version_id}' nicht gefunden")
|
||||
shutil.rmtree(archive)
|
||||
manifest = read_manifest(name)
|
||||
if manifest is not None:
|
||||
manifest["version_history"] = [v for v in (manifest.get("version_history") or [])
|
||||
if v.get("version_id") != version_id]
|
||||
write_manifest(name, manifest)
|
||||
return {"ok": True, "deleted": version_id}
|
||||
|
||||
|
||||
# ─── Run ────────────────────────────────────────────────────────────
|
||||
|
||||
def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) -> dict:
|
||||
@@ -328,6 +605,19 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
||||
# Brain-API fuer Skills die OAuth-Tokens / Brain-Helpers brauchen.
|
||||
# Beispiel: requests.get(f"{os.environ['BRAIN_INTERNAL_URL']}/oauth/spotify/token")
|
||||
env["BRAIN_INTERNAL_URL"] = os.environ.get("BRAIN_INTERNAL_URL", "http://localhost:8080")
|
||||
# Config-Schema-Werte als CFG_<NAME>-ENV (P3). Default greift wenn Stefan
|
||||
# noch keinen Wert gesetzt hat — None wird uebersprungen damit der Skill
|
||||
# selbst entscheiden kann ob das ein Fehler ist.
|
||||
schema = manifest.get("config_schema") or []
|
||||
values = get_skill_config(name)
|
||||
for field in schema:
|
||||
fname = field.get("name")
|
||||
if not fname:
|
||||
continue
|
||||
val = values.get(fname, field.get("default"))
|
||||
if val is None:
|
||||
continue
|
||||
env[_config_env_name(fname)] = str(val)
|
||||
|
||||
# Command bauen
|
||||
if exec_mode == "local-venv":
|
||||
|
||||
@@ -3514,6 +3514,8 @@
|
||||
<button class="btn secondary" onclick="exportSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#0096FF;border-color:#0096FF;">⬇ Export</button>
|
||||
<button class="btn secondary" onclick="deleteSkill('${escapeHtml(s.name)}')" style="padding:2px 10px;font-size:11px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
|
||||
</div>
|
||||
<div id="skill-config-${escapeHtml(s.name)}" style="margin-bottom:10px;"></div>
|
||||
<div id="skill-versions-${escapeHtml(s.name)}" style="margin-bottom:10px;"></div>
|
||||
<div style="color:#0096FF;font-size:11px;font-weight:bold;margin:6px 0 4px;">Logs (letzte 20)</div>
|
||||
<div id="skill-logs-${escapeHtml(s.name)}" style="font-size:11px;color:#8888AA;">(Logs lädt...)</div>
|
||||
</div>
|
||||
@@ -3547,6 +3549,8 @@
|
||||
const el = document.getElementById('skill-readme-' + name);
|
||||
if (el && d.readme) el.innerHTML = '<pre style="margin:0;font-family:inherit;white-space:pre-wrap;">' + escapeHtml(d.readme) + '</pre>';
|
||||
} catch {}
|
||||
loadSkillConfigSection(name);
|
||||
loadSkillVersionsSection(name);
|
||||
try {
|
||||
const r2 = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/logs');
|
||||
const d2 = await r2.json();
|
||||
@@ -3565,6 +3569,155 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skill-Configs (P3) ─────────────────────────────────
|
||||
async function loadSkillConfigSection(name) {
|
||||
const el = document.getElementById('skill-config-' + name);
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config');
|
||||
if (!r.ok) { el.innerHTML = ''; return; }
|
||||
const d = await r.json();
|
||||
const schema = d.schema || [];
|
||||
if (!schema.length) { el.innerHTML = ''; return; }
|
||||
const values = d.values || {};
|
||||
const inputs = schema.map(f => {
|
||||
const fname = f.name;
|
||||
const label = f.label || fname;
|
||||
const desc = f.description ? `<div style="color:#555570;font-size:10px;">${escapeHtml(f.description)}</div>` : '';
|
||||
const isSecret = f.secret || f.type === 'password';
|
||||
const cur = values[fname];
|
||||
const placeholder = isSecret && cur === '***SET***' ? '••• gesetzt (leer lassen = unverändert) •••'
|
||||
: (f.default !== undefined && f.default !== null ? `Default: ${f.default}` : '');
|
||||
let inputEl;
|
||||
if (f.type === 'boolean') {
|
||||
const checked = (cur === true || cur === 'true') ? 'checked' : '';
|
||||
inputEl = `<input type="checkbox" data-cfg="${escapeHtml(fname)}" data-type="boolean" ${checked} style="margin-right:6px;">`;
|
||||
} else {
|
||||
const type = isSecret ? 'password' : (f.type === 'number' ? 'number' : 'text');
|
||||
const val = (isSecret) ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? escapeHtml(String(cur)) : '');
|
||||
inputEl = `<input type="${type}" data-cfg="${escapeHtml(fname)}" data-type="${f.type || 'string'}" value="${val}" placeholder="${escapeHtml(placeholder)}" style="flex:1;padding:3px 6px;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:3px;font-size:11px;">`;
|
||||
}
|
||||
return `<div style="margin-bottom:6px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<label style="min-width:120px;color:#8888AA;font-size:11px;">${escapeHtml(label)}${isSecret ? ' 🔒' : ''}</label>
|
||||
${inputEl}
|
||||
</div>
|
||||
${desc}
|
||||
</div>`;
|
||||
}).join('');
|
||||
el.innerHTML = `
|
||||
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;padding:8px;">
|
||||
<div style="color:#FFD60A;font-size:11px;font-weight:bold;margin-bottom:6px;">⚙ Konfiguration</div>
|
||||
${inputs}
|
||||
<button class="btn secondary" onclick="saveSkillConfig('${escapeHtml(name)}')" style="padding:3px 12px;font-size:11px;color:#3FFF3F;border-color:#3FFF3F;margin-top:4px;">💾 Speichern</button>
|
||||
<span id="skill-cfg-status-${escapeHtml(name)}" style="color:#8888AA;font-size:11px;margin-left:8px;"></span>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="color:#FF6B6B;font-size:11px;">Config-Load: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSkillConfig(name) {
|
||||
const el = document.getElementById('skill-config-' + name);
|
||||
if (!el) return;
|
||||
const inputs = el.querySelectorAll('[data-cfg]');
|
||||
// Erst aktuelle gespeicherte Werte holen — secret-Felder die leer sind sollen unverändert bleiben
|
||||
let existing = {};
|
||||
try {
|
||||
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config');
|
||||
const d = await r.json();
|
||||
existing = d.values || {};
|
||||
} catch {}
|
||||
const values = { ...existing };
|
||||
inputs.forEach(inp => {
|
||||
const fname = inp.getAttribute('data-cfg');
|
||||
const type = inp.getAttribute('data-type');
|
||||
let v;
|
||||
if (type === 'boolean') v = inp.checked;
|
||||
else if (type === 'number') v = inp.value === '' ? null : Number(inp.value);
|
||||
else v = inp.value;
|
||||
const isPassword = inp.type === 'password';
|
||||
if (isPassword && v === '') return; // leer bei secret = unverändert
|
||||
if (v === '' || v === null) { delete values[fname]; return; }
|
||||
if (v === '***SET***') return;
|
||||
values[fname] = v;
|
||||
});
|
||||
try {
|
||||
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/config', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
const stat = document.getElementById('skill-cfg-status-' + name);
|
||||
if (r.ok) {
|
||||
if (stat) { stat.textContent = '✓ gespeichert'; stat.style.color = '#3FFF3F'; }
|
||||
loadSkillConfigSection(name);
|
||||
} else {
|
||||
if (stat) { stat.textContent = 'Fehler ' + r.status; stat.style.color = '#FF6B6B'; }
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Speichern fehlgeschlagen: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skill-Versions (P4) ─────────────────────────────────
|
||||
async function loadSkillVersionsSection(name) {
|
||||
const el = document.getElementById('skill-versions-' + name);
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/versions');
|
||||
if (!r.ok) { el.innerHTML = ''; return; }
|
||||
const d = await r.json();
|
||||
const versions = d.versions || [];
|
||||
if (!versions.length) { el.innerHTML = ''; return; }
|
||||
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('de-DE') : '?';
|
||||
const rows = versions.map(v => `
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid #1E1E2E;">
|
||||
<span style="flex:1;font-family:monospace;font-size:10px;color:#E0E0F0;">${escapeHtml(v.version_id)}</span>
|
||||
<span style="font-size:10px;color:#8888AA;">${fmtDate(v.archived_at)}</span>
|
||||
<span style="flex:2;font-size:10px;color:#8888AA;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(v.summary || '')}</span>
|
||||
<button class="btn secondary" onclick="rollbackSkillVersion('${escapeHtml(name)}','${escapeHtml(v.version_id)}')" style="padding:1px 8px;font-size:10px;color:#FFD60A;border-color:#FFD60A;">↺ Rollback</button>
|
||||
<button class="btn secondary" onclick="deleteSkillVersion('${escapeHtml(name)}','${escapeHtml(v.version_id)}')" style="padding:1px 8px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑</button>
|
||||
</div>
|
||||
`).join('');
|
||||
el.innerHTML = `
|
||||
<div style="background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;padding:8px;">
|
||||
<div style="color:#FFD60A;font-size:11px;font-weight:bold;margin-bottom:6px;">📦 Versionen (${versions.length})</div>
|
||||
${rows}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="color:#FF6B6B;font-size:11px;">Versions-Load: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackSkillVersion(name, versionId) {
|
||||
if (!confirm(`Skill "${name}" auf Version ${versionId} zurückrollen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/rollback', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ version_id: versionId }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
alert(`✓ Rollback OK\nSicherheits-Snapshot: ${d.safety_snapshot}`);
|
||||
loadSkillVersionsSection(name);
|
||||
loadSkills();
|
||||
} else {
|
||||
alert('Rollback fehlgeschlagen: ' + (d.detail || JSON.stringify(d)));
|
||||
}
|
||||
} catch (e) { alert('Rollback-Fehler: ' + e.message); }
|
||||
}
|
||||
|
||||
async function deleteSkillVersion(name, versionId) {
|
||||
if (!confirm(`Version ${versionId} von "${name}" wirklich löschen?\n\nNicht rückholbar.`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/brain/skills/' + encodeURIComponent(name) + '/versions/' + encodeURIComponent(versionId), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) loadSkillVersionsSection(name);
|
||||
else { const d = await r.json().catch(()=>({})); alert('Löschen fehlgeschlagen: ' + (d.detail || r.status)); }
|
||||
} catch (e) { alert('Fehler: ' + e.message); }
|
||||
}
|
||||
|
||||
async function toggleSkillActive(name, newActive) {
|
||||
try {
|
||||
await fetch('/api/brain/skills/' + encodeURIComponent(name), {
|
||||
|
||||
Reference in New Issue
Block a user