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
+153
View File
@@ -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), {