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:
praisonai-triage-agent[bot] authored and GitHub committed 2026-07-15 15:23:05 +00:00
commit c3e330454c
5 files changed
+685 -43

No files matched your search

+33
View File
@@ -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;
}
+223
View File
@@ -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)