mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Merge pull request #227 from MervinPraison/claude/issue-218-20260715-000000
feat: Skills Studio — pending/diff/history for self_improve skill writes
This commit is contained in:
commit
c3e330454c
5 files changed
+685
-43
No files matched your search
@@ -81,6 +81,17 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.compaction.compacted": "Context compacted",
|
||||
"chat.memory.chip": "{count} memories",
|
||||
"chat.memory.empty_turn": "No memories for this turn",
|
||||
"skills.title": "Skills Studio",
|
||||
"skills.subtitle": "Review self_improve proposals",
|
||||
"skills.tab.pending": "Pending",
|
||||
"skills.tab.installed": "Installed",
|
||||
"skills.tab.history": "History",
|
||||
"skills.diff.current": "Current",
|
||||
"skills.diff.proposed": "Proposed",
|
||||
"skills.diff.no_existing": "No existing skill",
|
||||
"skills.action.approve": "Approve",
|
||||
"skills.action.reject": "Reject",
|
||||
"skills.pending.empty": "No pending skill writes",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Settings",
|
||||
@@ -102,6 +113,17 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.compaction.compacted": "Contexto compactado",
|
||||
"chat.memory.chip": "{count} recuerdos",
|
||||
"chat.memory.empty_turn": "Sin recuerdos para este turno",
|
||||
"skills.title": "Estudio de habilidades",
|
||||
"skills.subtitle": "Revisar propuestas de self_improve",
|
||||
"skills.tab.pending": "Pendientes",
|
||||
"skills.tab.installed": "Instaladas",
|
||||
"skills.tab.history": "Historial",
|
||||
"skills.diff.current": "Actual",
|
||||
"skills.diff.proposed": "Propuesto",
|
||||
"skills.diff.no_existing": "No existe la habilidad",
|
||||
"skills.action.approve": "Aprobar",
|
||||
"skills.action.reject": "Rechazar",
|
||||
"skills.pending.empty": "No hay escrituras de habilidades pendientes",
|
||||
"nav.dashboard": "Panel",
|
||||
"nav.agents": "Agentes",
|
||||
"nav.settings": "Configuración",
|
||||
@@ -123,6 +145,17 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.compaction.compacted": "Contexte compacté",
|
||||
"chat.memory.chip": "{count} souvenirs",
|
||||
"chat.memory.empty_turn": "Aucun souvenir pour ce tour",
|
||||
"skills.title": "Studio de comp\u00e9tences",
|
||||
"skills.subtitle": "Examiner les propositions de self_improve",
|
||||
"skills.tab.pending": "En attente",
|
||||
"skills.tab.installed": "Install\u00e9es",
|
||||
"skills.tab.history": "Historique",
|
||||
"skills.diff.current": "Actuel",
|
||||
"skills.diff.proposed": "Propos\u00e9",
|
||||
"skills.diff.no_existing": "Aucune comp\u00e9tence existante",
|
||||
"skills.action.approve": "Approuver",
|
||||
"skills.action.reject": "Rejeter",
|
||||
"skills.pending.empty": "Aucune \u00e9criture de comp\u00e9tence en attente",
|
||||
"nav.dashboard": "Tableau de bord",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Paramètres",
|
||||
|
||||
@@ -243,3 +243,103 @@ export function modalShell(id, innerHtml) {
|
||||
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;max-width:520px;width:100%;max-height:90vh;overflow:auto;padding:20px">${innerHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-level diff between two text blocks using an LCS table.
|
||||
* Normalises CRLF/CR to LF before comparing. Bounded for large inputs.
|
||||
*
|
||||
* @param {string} before
|
||||
* @param {string} after
|
||||
* @param {number} [maxLines=2000] - Truncate each side to this many lines.
|
||||
* @returns {{type:('add'|'del'|'ctx'), text:string}[]}
|
||||
*/
|
||||
export function diffLines(before, after, maxLines = 2000) {
|
||||
const norm = (s) => String(s == null ? '' : s).replace(/\r\n?/g, '\n');
|
||||
const a = norm(before).split('\n').slice(0, maxLines);
|
||||
const b = norm(after).split('\n').slice(0, maxLines);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ type: 'ctx', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
out.push({ type: 'del', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ type: 'add', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal, XSS-safe markdown preview. All content is escaped first; only a
|
||||
* small, fixed set of inline/block constructs are re-enabled. No raw HTML,
|
||||
* scripts, or attributes from source ever reach the DOM.
|
||||
*
|
||||
* @param {string} md
|
||||
* @returns {string} sanitised HTML string
|
||||
*/
|
||||
export function renderMarkdownPreview(md) {
|
||||
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
|
||||
const inline = (s) => esc(s)
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
const html = [];
|
||||
let inList = false;
|
||||
const closeList = () => { if (inList) { html.push('</ul>'); inList = false; } };
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\t/g, ' ');
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
if (h) {
|
||||
closeList();
|
||||
const lvl = h[1].length;
|
||||
html.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
|
||||
} else if (li) {
|
||||
if (!inList) { html.push('<ul>'); inList = true; }
|
||||
html.push(`<li>${inline(li[1])}</li>`);
|
||||
} else if (line.trim() === '') {
|
||||
closeList();
|
||||
} else {
|
||||
closeList();
|
||||
html.push(`<p>${inline(line)}</p>`);
|
||||
}
|
||||
}
|
||||
closeList();
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a pending approval represents a skill write (rules R1-R5).
|
||||
*
|
||||
* @param {Object} a - approval record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSkillApproval(a) {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const NAMES = ['write_skill', 'skill_write', 'save_skill', 'create_skill'];
|
||||
const tool = String(a.tool_name || '').toLowerCase();
|
||||
const action = String(a.action || '').toLowerCase();
|
||||
if (NAMES.includes(tool) || NAMES.includes(action)) return true;
|
||||
if (String(a.type || '').toLowerCase() === 'skill_write') return true;
|
||||
if (a.metadata && String(a.metadata.kind || '').toLowerCase() === 'skill') return true;
|
||||
const path = String((a.arguments && a.arguments.path) || '');
|
||||
if (path.endsWith('SKILL.md') || path.includes('.praisonai/skills/')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* Approvals View — execution approval queue
|
||||
* API: /api/approvals
|
||||
*/
|
||||
import { isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
@@ -33,11 +35,13 @@ export async function render(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
|
||||
const isSkill = isSkillApproval(a);
|
||||
card.innerHTML = `
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
|
||||
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
|
||||
|
||||
@@ -1,72 +1,354 @@
|
||||
/**
|
||||
* Skills View — tool/skill catalog with enable/disable toggles
|
||||
* API: /api/skills
|
||||
* Skills Studio (STITCH-008) — unified Pending → Diff → Approve → Test →
|
||||
* Installed lifecycle for self_improve skill writes.
|
||||
*
|
||||
* Tabs: Pending (skill-filtered approvals) · Installed (toggle catalog) · History.
|
||||
* URL state: /skills?tab=pending|installed|history&approval_id={id}
|
||||
*
|
||||
* Reuses existing /api/approvals and /api/skills endpoints; no new backend.
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
import { esc, timeAgo, diffLines, renderMarkdownPreview, isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
|
||||
let tools = [], categories = [];
|
||||
try { const r = await fetch('/api/skills'); const d = await r.json(); tools = d.tools || d.skills || d || []; if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id,t]) => ({id,...(typeof t==='object'?t:{name:t})})); } catch(e) {}
|
||||
try { const r = await fetch('/api/skills/categories'); const d = await r.json(); categories = d.categories || d || []; } catch(e) {}
|
||||
const RISK_ICONS = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
|
||||
|
||||
// Group by category
|
||||
const grouped = {};
|
||||
tools.forEach(t => { const cat = t.category || 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(t); });
|
||||
let _container = null;
|
||||
|
||||
const enabledCount = tools.filter(t => t.enabled !== false).length;
|
||||
function currentTab() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
return p.get('tab') || '';
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
|
||||
<div class="db-card"><div class="db-card-title">Total Skills</div><div class="db-card-value">${tools.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${Object.keys(grouped).length}</div></div>
|
||||
function currentApprovalId() {
|
||||
return new URLSearchParams(location.search).get('approval_id') || '';
|
||||
}
|
||||
|
||||
function setUrlState(tab, approvalId) {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (tab) p.set('tab', tab); else p.delete('tab');
|
||||
if (approvalId) p.set('approval_id', approvalId); else p.delete('approval_id');
|
||||
history.replaceState(history.state, '', `${location.pathname}?${p.toString()}`);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function approvalId(a) {
|
||||
return a.id || a.approval_id || '';
|
||||
}
|
||||
|
||||
function skillName(a) {
|
||||
const args = a.arguments || {};
|
||||
if (args.skill_name) return args.skill_name;
|
||||
if (args.name) return args.name;
|
||||
const path = String(args.path || '');
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (path.endsWith('SKILL.md') && parts.length >= 2) return parts[parts.length - 2];
|
||||
return a.tool_name || a.action || 'skill';
|
||||
}
|
||||
|
||||
function proposedContent(a) {
|
||||
const args = a.arguments || {};
|
||||
return a.proposed_content || args.content || args.skill_md || '';
|
||||
}
|
||||
|
||||
function changeType(a) {
|
||||
if (a.current_content) return 'Update';
|
||||
const args = a.arguments || {};
|
||||
if (String(args.op || a.type || '').toLowerCase().includes('delete')) return 'Delete';
|
||||
return 'New';
|
||||
}
|
||||
|
||||
// ── Diff overlay ────────────────────────────────────────────────────
|
||||
|
||||
function renderDiffRows(before, after) {
|
||||
const rows = diffLines(before, after);
|
||||
const colorFor = { add: 'rgba(34,197,94,.12)', del: 'rgba(239,68,68,.12)', ctx: 'transparent' };
|
||||
const signFor = { add: '+', del: '-', ctx: ' ' };
|
||||
let adds = 0;
|
||||
let dels = 0;
|
||||
const body = rows.map((r) => {
|
||||
if (r.type === 'add') adds++;
|
||||
if (r.type === 'del') dels++;
|
||||
return `<div style="display:flex;font-family:monospace;font-size:12px;background:${colorFor[r.type]}">
|
||||
<span style="width:16px;text-align:center;color:var(--db-text-dim);flex-shrink:0">${signFor[r.type]}</span>
|
||||
<span style="white-space:pre-wrap;word-break:break-word">${esc(r.text)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return { html: body, adds, dels };
|
||||
}
|
||||
|
||||
function diffOverlayHtml(a, skills) {
|
||||
const before = a.current_content || '';
|
||||
const after = proposedContent(a);
|
||||
const hasProposed = after !== '';
|
||||
const { html: rawRows, adds, dels } = renderDiffRows(before, after);
|
||||
const name = skillName(a);
|
||||
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
|
||||
const leftEmpty = before === '';
|
||||
return `
|
||||
<div class="ss-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;display:flex;justify-content:flex-end">
|
||||
<div class="ss-panel" style="width:60%;max-width:920px;height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);display:flex;flex-direction:column;box-sizing:border-box">
|
||||
<div style="padding:16px 20px;border-bottom:1px solid var(--db-border);display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600">${esc(name)}
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
|
||||
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">${RISK_ICONS[a.risk_level] || ''} ${esc(a.agent_name || '')} · +${adds} −${dels}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="ss-mode" data-mode="raw" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:var(--db-accent);color:#fff;border-radius:6px;cursor:pointer">Raw</button>
|
||||
<button class="ss-mode" data-mode="preview" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Preview</button>
|
||||
<button class="ss-close" style="background:none;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer">×</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasProposed ? '' : '<div style="padding:12px 20px;background:rgba(239,68,68,.1);color:#ef4444;font-size:13px">Content unavailable — approval disabled</div>'}
|
||||
<div class="ss-diff-body" style="flex:1;overflow:auto;padding:16px 20px">
|
||||
<div class="ss-columns" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Current</div>
|
||||
<div class="ss-left">${leftEmpty ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Proposed</div>
|
||||
<div class="ss-right">${rawRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid var(--db-border);display:flex;gap:10px;justify-content:flex-end">
|
||||
<button class="ss-deny" data-id="${esc(approvalId(a))}" style="padding:8px 18px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer;font-size:13px">Reject</button>
|
||||
<button class="ss-approve" data-id="${esc(approvalId(a))}" ${hasProposed ? '' : 'disabled'} style="padding:8px 18px;background:${hasProposed ? 'rgba(34,197,94,.15)' : 'var(--db-card-bg)'};color:${hasProposed ? '#22c55e' : 'var(--db-text-dim)'};border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:${hasProposed ? 'pointer' : 'not-allowed'};font-size:13px">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openDiff(a, skills) {
|
||||
const host = _container.querySelector('#ss-overlay-host');
|
||||
if (!host) return;
|
||||
host.innerHTML = diffOverlayHtml(a, skills);
|
||||
setUrlState('pending', approvalId(a));
|
||||
const before = a.current_content || '';
|
||||
const after = proposedContent(a);
|
||||
|
||||
const close = () => { host.innerHTML = ''; setUrlState('pending', ''); };
|
||||
host.querySelector('.ss-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
host.querySelector('.ss-close').addEventListener('click', close);
|
||||
|
||||
host.querySelectorAll('.ss-mode').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.mode;
|
||||
host.querySelectorAll('.ss-mode').forEach((b) => {
|
||||
const on = b === btn;
|
||||
b.style.background = on ? 'var(--db-accent)' : 'transparent';
|
||||
b.style.color = on ? '#fff' : 'var(--db-text)';
|
||||
});
|
||||
const left = host.querySelector('.ss-left');
|
||||
const right = host.querySelector('.ss-right');
|
||||
if (mode === 'preview') {
|
||||
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : renderMarkdownPreview(before);
|
||||
right.innerHTML = renderMarkdownPreview(after);
|
||||
} else {
|
||||
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`;
|
||||
right.innerHTML = renderDiffRows(before, after).html;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const resolve = async (verb, id) => {
|
||||
try { await fetch(`/api/approvals/${id}/${verb}`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
close();
|
||||
paint();
|
||||
};
|
||||
host.querySelector('.ss-approve')?.addEventListener('click', (e) => {
|
||||
if (e.currentTarget.disabled) return;
|
||||
resolve('approve', e.currentTarget.dataset.id);
|
||||
});
|
||||
host.querySelector('.ss-deny')?.addEventListener('click', (e) => resolve('deny', e.currentTarget.dataset.id));
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
function pendingCard(a, skills) {
|
||||
const name = skillName(a);
|
||||
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
|
||||
const preview = proposedContent(a).slice(0, 200);
|
||||
return `<div class="ss-pending-card db-card" data-id="${esc(approvalId(a))}" style="margin-bottom:10px;padding:16px 20px;cursor:pointer">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:14px">${esc(name.slice(0, 40))}
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
|
||||
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">🤖 ${esc(a.agent_name || '—')} · ${esc((a.metadata && a.metadata.source) || 'self_improve')} · ${esc(timeAgo(a.created_at))}</div>
|
||||
</div>
|
||||
<div style="font-size:12px">${RISK_ICONS[a.risk_level] || ''}</div>
|
||||
</div>
|
||||
<div id="skills-list"></div>
|
||||
`;
|
||||
${preview ? `<pre style="margin:10px 0 0;font-size:11px;color:var(--db-text-dim);white-space:pre-wrap;max-height:44px;overflow:hidden">${esc(preview)}</pre>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const list = container.querySelector('#skills-list');
|
||||
for (const [category, catTools] of Object.entries(grouped)) {
|
||||
const section = document.createElement('div');
|
||||
section.style.marginBottom = '20px';
|
||||
section.innerHTML = `<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${category} (${catTools.length})</h3>`;
|
||||
function pendingTab(pending, skills) {
|
||||
if (pending.length === 0) {
|
||||
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">📖</div>
|
||||
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No pending skill writes</div>
|
||||
<div style="font-size:13px">Set <code>SKILL_WRITE_APPROVAL=1</code> and run an agent with <code>self_improve</code> to stage proposals here.</div>
|
||||
</div>`;
|
||||
}
|
||||
return pending.map((a) => pendingCard(a, skills)).join('');
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'db-columns';
|
||||
grid.style.gridTemplateColumns = 'repeat(auto-fill,minmax(280px,1fr))';
|
||||
|
||||
catTools.forEach(t => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'padding:14px 18px;display:flex;align-items:center;justify-content:space-between';
|
||||
function installedTab(tools, pendingNames) {
|
||||
const grouped = {};
|
||||
tools.forEach((t) => { const c = t.category || 'other'; (grouped[c] = grouped[c] || []).push(t); });
|
||||
if (tools.length === 0) return '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
|
||||
return Object.entries(grouped).map(([category, catTools]) => {
|
||||
const cards = catTools.map((t) => {
|
||||
const enabled = t.enabled !== false;
|
||||
const icon = t.icon || '🔧';
|
||||
const keyStatus = t.api_key_set ? '🔑' : '';
|
||||
card.innerHTML = `
|
||||
const hasPending = pendingNames.has(t.name || t.id);
|
||||
return `<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex:1">
|
||||
<span style="font-size:20px">${icon}</span>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500">${t.name || t.id} ${keyStatus}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${t.description || ''}</div>
|
||||
<div style="font-size:13px;font-weight:500">${esc(t.name || t.id)} ${keyStatus}${hasPending ? ' <span title="Pending change" style="color:#eab308">●</span>' : ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(t.description || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label style="position:relative;display:inline-block;width:40px;height:22px;cursor:pointer">
|
||||
<input type="checkbox" class="skill-toggle" data-id="${t.id || t.name}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
|
||||
<input type="checkbox" class="skill-toggle" data-id="${esc(t.id || t.name)}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
|
||||
<span style="position:absolute;inset:0;background:${enabled ? 'var(--db-accent)' : 'var(--db-border)'};border-radius:22px;transition:0.3s"></span>
|
||||
<span style="position:absolute;top:2px;left:${enabled ? '20px' : '2px'};width:18px;height:18px;background:#fff;border-radius:50%;transition:0.3s"></span>
|
||||
</label>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin-bottom:20px">
|
||||
<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${esc(category)} (${catTools.length})</h3>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">${cards}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
section.appendChild(grid);
|
||||
list.appendChild(section);
|
||||
function historyTab(history) {
|
||||
const skillHist = (Array.isArray(history) ? history : []).filter(isSkillApproval);
|
||||
if (skillHist.length === 0) return '<div style="font-size:13px;color:var(--db-text-dim)">No skill approval history</div>';
|
||||
return skillHist.slice(0, 25).map((h) => {
|
||||
const approved = h.status === 'approved' || h.approved;
|
||||
return `<div style="padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between;gap:10px">
|
||||
<span>${esc(skillName(h))} — ${esc(h.agent_name || '')}</span>
|
||||
<span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'} · ${esc(timeAgo(h.resolved_at || h.created_at))}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const STUDIO_STYLE = `<style id="ss-style">
|
||||
@media (max-width: 768px) {
|
||||
.ss-panel { width: 100% !important; }
|
||||
.ss-columns { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
if (tools.length === 0) list.innerHTML = '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
|
||||
function shell(data) {
|
||||
const { pending, tools } = data;
|
||||
const tab = data.activeTab;
|
||||
const enabledCount = tools.filter((t) => t.enabled !== false).length;
|
||||
const catCount = new Set(tools.map((t) => t.category || 'other')).size;
|
||||
const tabBtn = (id, label, badge) => {
|
||||
const on = tab === id;
|
||||
return `<button class="ss-tab" data-tab="${id}" style="padding:8px 16px;border:none;border-bottom:2px solid ${on ? 'var(--db-accent)' : 'transparent'};background:none;color:${on ? 'var(--db-text)' : 'var(--db-text-dim)'};font-size:13px;font-weight:${on ? '600' : '400'};cursor:pointer">${label}${badge ? ` <span style="background:rgba(234,179,8,.2);color:#eab308;padding:1px 7px;border-radius:10px;font-size:11px;margin-left:4px">${badge}</span>` : ''}</button>`;
|
||||
};
|
||||
let body = '';
|
||||
if (tab === 'installed') body = installedTab(tools, data.pendingNames);
|
||||
else if (tab === 'history') body = historyTab(data.history);
|
||||
else body = pendingTab(pending, tools);
|
||||
|
||||
container.querySelectorAll('.skill-toggle').forEach(cb => {
|
||||
return `${STUDIO_STYLE}
|
||||
<div style="margin-bottom:8px">
|
||||
<h2 style="margin:0;font-size:1.1rem;font-weight:600">Skills Studio</h2>
|
||||
<div style="font-size:13px;color:var(--db-text-dim)">Review self_improve proposals</div>
|
||||
</div>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin:16px 0 20px">
|
||||
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${catCount}</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;border-bottom:1px solid var(--db-border);margin-bottom:16px">
|
||||
${tabBtn('pending', 'Pending', pending.length || '')}
|
||||
${tabBtn('installed', 'Installed', '')}
|
||||
${tabBtn('history', 'History', '')}
|
||||
</div>
|
||||
<div id="ss-body">${body}</div>
|
||||
<div id="ss-overlay-host"></div>`;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson('/api/skills'),
|
||||
fetchJson('/api/approvals/pending'),
|
||||
fetchJson('/api/approvals/history'),
|
||||
]);
|
||||
const val = (i, f) => (results[i].status === 'fulfilled' ? results[i].value : f);
|
||||
const sk = val(0, {});
|
||||
let tools = sk.tools || sk.skills || sk || [];
|
||||
if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id, t]) => ({ id, ...(typeof t === 'object' ? t : { name: t }) }));
|
||||
const rawPending = (val(1, {}).approvals) || [];
|
||||
const history = (val(2, {}).approvals) || (val(2, {}).history) || [];
|
||||
const pending = rawPending.filter(isSkillApproval);
|
||||
const pendingNames = new Set(pending.map((a) => skillName(a)));
|
||||
return { tools, pending, history, pendingNames };
|
||||
}
|
||||
|
||||
function bindEvents(data) {
|
||||
_container.querySelectorAll('.ss-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
data.activeTab = btn.dataset.tab;
|
||||
setUrlState(data.activeTab, '');
|
||||
renderShell(data);
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('.ss-pending-card').forEach((card) => {
|
||||
card.addEventListener('click', () => {
|
||||
const a = data.pending.find((x) => approvalId(x) === card.dataset.id);
|
||||
if (a) openDiff(a, data.tools);
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('.skill-toggle').forEach((cb) => {
|
||||
cb.addEventListener('change', async () => {
|
||||
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, {method:'POST'}); render(container); } catch(e) {}
|
||||
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
paint();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(data) {
|
||||
_container.innerHTML = shell(data);
|
||||
bindEvents(data);
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
if (!_container) return;
|
||||
const data = await loadAll();
|
||||
const tab = currentTab() || (data.pending.length > 0 ? 'pending' : 'installed');
|
||||
data.activeTab = tab;
|
||||
renderShell(data);
|
||||
const deepId = currentApprovalId();
|
||||
if (deepId && tab === 'pending') {
|
||||
const a = data.pending.find((x) => approvalId(x) === deepId);
|
||||
if (a) openDiff(a, data.tools);
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
await paint();
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
_container = null;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Unit tests for Skills Studio (STITCH-008, #218).
|
||||
|
||||
Covers:
|
||||
* skills.* i18n keys across locales.
|
||||
* Frontend view content assertions (tabs, diff overlay, deep-link, APIs).
|
||||
* XSS-safety of the proposed-content diff render.
|
||||
* approvals.js cross-link for skill-type rows.
|
||||
* Python-mirrored isSkillApproval (R1-R5) predicate and LCS line diff.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiui.features.i18n import JSONLocaleManager
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
|
||||
)
|
||||
SKILLS_JS = FRONTEND / "skills.js"
|
||||
APPROVALS_JS = FRONTEND / "approvals.js"
|
||||
HELPERS_JS = FRONTEND / "_helpers.js"
|
||||
|
||||
|
||||
class TestI18nKeys:
|
||||
KEYS = [
|
||||
"skills.title",
|
||||
"skills.subtitle",
|
||||
"skills.tab.pending",
|
||||
"skills.tab.installed",
|
||||
"skills.tab.history",
|
||||
"skills.diff.current",
|
||||
"skills.diff.proposed",
|
||||
"skills.diff.no_existing",
|
||||
"skills.action.approve",
|
||||
"skills.action.reject",
|
||||
"skills.pending.empty",
|
||||
]
|
||||
|
||||
def test_keys_present_all_locales(self):
|
||||
mgr = JSONLocaleManager()
|
||||
for locale in ("en", "es", "fr"):
|
||||
strings = mgr.get_strings(locale)
|
||||
for k in self.KEYS:
|
||||
assert k in strings, f"missing {locale} key: {k}"
|
||||
|
||||
|
||||
class TestHelpersJs:
|
||||
def test_helpers_export_functions(self):
|
||||
src = HELPERS_JS.read_text()
|
||||
assert "export function diffLines(" in src
|
||||
assert "export function renderMarkdownPreview(" in src
|
||||
assert "export function isSkillApproval(" in src
|
||||
|
||||
def test_diff_normalises_crlf(self):
|
||||
src = HELPERS_JS.read_text()
|
||||
assert "replace(/\\r\\n?/g, '\\n')" in src
|
||||
|
||||
def test_preview_escapes_first(self):
|
||||
src = HELPERS_JS.read_text()
|
||||
assert "esc(s)" in src
|
||||
|
||||
|
||||
class TestSkillsJs:
|
||||
def test_exports_render_and_cleanup(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "export async function render" in src
|
||||
assert "export function cleanup" in src
|
||||
|
||||
def test_tab_shell_present(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "class=\"ss-tab\"" in src
|
||||
assert "tabBtn('pending'" in src
|
||||
assert "tabBtn('installed'" in src
|
||||
assert "tabBtn('history'" in src
|
||||
|
||||
def test_url_state_and_deep_link(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "approval_id" in src
|
||||
assert "currentApprovalId(" in src
|
||||
assert "openDiff(" in src
|
||||
|
||||
def test_uses_existing_apis(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "/api/approvals/pending" in src
|
||||
assert "/api/approvals/history" in src
|
||||
assert "/api/skills" in src
|
||||
assert "/api/skills/${cb.dataset.id}/toggle" in src
|
||||
assert "/api/approvals/${id}/${verb}" in src
|
||||
|
||||
def test_imports_skill_helpers(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "isSkillApproval" in src
|
||||
assert "diffLines" in src
|
||||
assert "renderMarkdownPreview" in src
|
||||
|
||||
def test_no_browser_eval(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "eval(" not in src
|
||||
|
||||
def test_proposed_content_escaped_in_diff(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
# Diff rows must escape line text; no raw interpolation of r.text.
|
||||
assert "esc(r.text)" in src
|
||||
assert "${r.text}" not in src
|
||||
|
||||
def test_empty_state_and_no_existing_labels(self):
|
||||
src = SKILLS_JS.read_text()
|
||||
assert "No pending skill writes" in src
|
||||
assert "No existing skill" in src
|
||||
|
||||
|
||||
class TestApprovalsCrossLink:
|
||||
def test_skill_row_links_to_studio(self):
|
||||
src = APPROVALS_JS.read_text()
|
||||
assert "isSkillApproval" in src
|
||||
assert "/skills?tab=pending&approval_id=" in src
|
||||
|
||||
|
||||
# ── Python mirrors of the JS predicate + diff for logic verification ──
|
||||
|
||||
|
||||
def is_skill_approval(a: dict) -> bool:
|
||||
names = {"write_skill", "skill_write", "save_skill", "create_skill"}
|
||||
if str(a.get("tool_name", "")).lower() in names:
|
||||
return True
|
||||
if str(a.get("action", "")).lower() in names:
|
||||
return True
|
||||
if str(a.get("type", "")).lower() == "skill_write":
|
||||
return True
|
||||
meta = a.get("metadata") or {}
|
||||
if str(meta.get("kind", "")).lower() == "skill":
|
||||
return True
|
||||
path = str((a.get("arguments") or {}).get("path", ""))
|
||||
if path.endswith("SKILL.md") or ".praisonai/skills/" in path:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def diff_lines(before: str, after: str) -> list[tuple[str, str]]:
|
||||
a = before.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
b = after.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
n, m = len(a), len(b)
|
||||
lcs = [[0] * (m + 1) for _ in range(n + 1)]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(m - 1, -1, -1):
|
||||
lcs[i][j] = (
|
||||
lcs[i + 1][j + 1] + 1
|
||||
if a[i] == b[j]
|
||||
else max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
)
|
||||
out: list[tuple[str, str]] = []
|
||||
i = j = 0
|
||||
while i < n and j < m:
|
||||
if a[i] == b[j]:
|
||||
out.append(("ctx", a[i]))
|
||||
i += 1
|
||||
j += 1
|
||||
elif lcs[i + 1][j] >= lcs[i][j + 1]:
|
||||
out.append(("del", a[i]))
|
||||
i += 1
|
||||
else:
|
||||
out.append(("add", b[j]))
|
||||
j += 1
|
||||
while i < n:
|
||||
out.append(("del", a[i]))
|
||||
i += 1
|
||||
while j < m:
|
||||
out.append(("add", b[j]))
|
||||
j += 1
|
||||
return out
|
||||
|
||||
|
||||
class TestSkillPredicate:
|
||||
def test_r1_tool_name(self):
|
||||
assert is_skill_approval({"tool_name": "write_skill"})
|
||||
assert is_skill_approval({"tool_name": "Create_Skill"})
|
||||
|
||||
def test_r2_action(self):
|
||||
assert is_skill_approval({"action": "save_skill"})
|
||||
|
||||
def test_r3_type(self):
|
||||
assert is_skill_approval({"type": "skill_write"})
|
||||
|
||||
def test_r4_metadata_kind(self):
|
||||
assert is_skill_approval({"metadata": {"kind": "skill"}})
|
||||
|
||||
def test_r5_path(self):
|
||||
assert is_skill_approval({"arguments": {"path": "x/y/SKILL.md"}})
|
||||
assert is_skill_approval({"arguments": {"path": ".praisonai/skills/z/x"}})
|
||||
|
||||
def test_non_skill_rejected(self):
|
||||
assert not is_skill_approval({"tool_name": "run_shell"})
|
||||
assert not is_skill_approval({"arguments": {"path": "notes.txt"}})
|
||||
assert not is_skill_approval({})
|
||||
|
||||
|
||||
class TestDiffLines:
|
||||
def test_add_and_delete_counts(self):
|
||||
rows = diff_lines("a\nb\nc", "a\nx\nc")
|
||||
adds = [r for r in rows if r[0] == "add"]
|
||||
dels = [r for r in rows if r[0] == "del"]
|
||||
ctx = [r for r in rows if r[0] == "ctx"]
|
||||
assert len(adds) == 1 and adds[0][1] == "x"
|
||||
assert len(dels) == 1 and dels[0][1] == "b"
|
||||
assert len(ctx) == 2
|
||||
|
||||
def test_new_skill_additions(self):
|
||||
rows = diff_lines("", "line1\nline2")
|
||||
assert any(r[0] == "add" and r[1] == "line1" for r in rows)
|
||||
assert any(r[0] == "add" and r[1] == "line2" for r in rows)
|
||||
|
||||
def test_crlf_normalised(self):
|
||||
rows = diff_lines("a\r\nb", "a\nb")
|
||||
assert all(r[0] == "ctx" for r in rows)
|
||||
|
||||
def test_hundred_line_diff(self):
|
||||
before = "\n".join(f"l{i}" for i in range(100))
|
||||
after = "\n".join(("CHANGED" if i == 50 else f"l{i}") for i in range(100))
|
||||
rows = diff_lines(before, after)
|
||||
assert any(r[0] == "add" and r[1] == "CHANGED" for r in rows)
|
||||
assert any(r[0] == "del" and r[1] == "l50" for r in rows)
|
||||
Reference in new issue
Block a user