Skip to content

Commit 5ae4c88

Browse files
AVADSA25Mikarina13claude
authored
feat(skills): persist skill reviews to disk + Skills review/approve UI (beat 21) (#255)
create_skill tells the user "Open Dashboard → Skills to review and approve it" but no such UI existed, and staged reviews lived only in an in-memory dict wiped on every codec-dashboard restart. This adds both halves. Backend (routes/skills.py): - /api/skill/review now also writes ~/.codec/skill_reviews/<id>.json (atomic via codec_jsonstore) so reviews survive a restart; the in-memory dict stays as a write-through cache for back-compat. - /api/skill/approve reads disk-first (falls back to the cache), keeps the is_dangerous_skill_code + pinned-builtin gates, and only deletes the review file AFTER a successful write (a blocked approve stays re-approvable). - new GET /api/skill/reviews → list staged reviews (id, filename, code, staged_at) - new POST /api/skill/reject/{id} → discard without writing - review_id is filename-sanitized (traversal-safe) for the reject path param. UI (codec_dashboard.html): - new "Skills" tab + panel: lists each staged skill's name, staged time and full code (monospace, scrollable) with Approve / Reject buttons; toasts the result and refreshes. Line-SVG icons, no emoji. - pending-review count badge on the tab, polled on the existing 30s cadence. Tests: new tests/test_skill_reviews_persistence.py (persistence, restart survival, list shape, approve-writes-and-deletes, reject-discards, traversal safety); existing skill-route tests kept green. Verified end-to-end in a browser against the real router: staged moon_phase.py, approved it (written to the skills dir, review deleted, badge cleared), and rejected a second review (nothing written). Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 40a7331 commit 5ae4c88

4 files changed

Lines changed: 502 additions & 3 deletions

File tree

codec_dashboard.html

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@
142142
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
143143
.tab .ico { opacity: 0.5; }
144144
.tab.active .ico { opacity: 1; color: var(--accent); }
145+
.tab-badge {
146+
display: inline-flex; align-items: center; justify-content: center;
147+
min-width: 16px; height: 16px; padding: 0 5px; margin-left: 1px;
148+
background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
149+
border-radius: 8px; line-height: 1;
150+
}
151+
.tab-badge.hidden { display: none; }
145152

146153
/* ── Content ── */
147154
.content { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; }
@@ -283,6 +290,26 @@
283290
.save-config-btn:active { transform: scale(0.98); }
284291
.save-config-btn.saved { background: var(--success); }
285292

293+
/* ── Skill Reviews ── */
294+
.skills-review-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
295+
.skills-review-sub { font-size: 12px; color: var(--text-dim); max-width: 560px; line-height: 1.45; margin-top: 3px; }
296+
.skills-refresh-btn { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 7px; color: var(--text-dim); cursor: pointer; flex-shrink: 0; display: flex; }
297+
.skills-refresh-btn:hover { color: var(--accent); border-color: var(--accent); }
298+
.skill-review-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow: hidden; }
299+
.skill-review-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
300+
.skill-review-name { font-family: var(--mono); font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; word-break: break-all; }
301+
.skill-review-meta { font-size: 11px; color: var(--text-dim); font-family: var(--mono); }
302+
.skill-review-actions { display: flex; gap: 8px; }
303+
.skill-btn { border: none; border-radius: 6px; padding: 7px 16px; font-weight: 600; font-size: 12px; cursor: pointer; font-family: var(--font); transition: opacity .15s, background .15s, border-color .15s, color .15s; }
304+
.skill-btn:hover { opacity: .88; }
305+
.skill-btn.approve { background: var(--success); color: #fff; }
306+
.skill-btn.reject { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
307+
.skill-btn.reject:hover { color: var(--danger); border-color: var(--danger); opacity: 1; }
308+
.skill-btn:disabled { opacity: .5; cursor: default; }
309+
.skill-review-code { margin: 0; padding: 12px 14px; font-family: var(--mono); font-size: 12px; line-height: 1.5; color: var(--text-muted); white-space: pre; overflow: auto; max-height: 340px; background: var(--bg); }
310+
.skills-empty { text-align: center; color: var(--text-dim); font-size: 13px; padding: 44px 20px; }
311+
.skills-empty svg { width: 38px; height: 38px; opacity: .3; margin-bottom: 10px; }
312+
286313
/* ── Prompt Editor ── */
287314
.prompt-card { background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;transition:all .2s }
288315
.prompt-card.modified { border-color:var(--accent) }
@@ -601,6 +628,9 @@ <h2>MENU</h2>
601628
<button class="tab" onclick="showTab('cortex')" id="tab-cortex">
602629
<svg class="ico ico-sm" viewBox="0 0 24 24"><path d="M12 2C9 2 6.5 3.8 5.5 6.5c-1.5.5-2.7 1.5-3.3 3-.6 1.4-.4 3.1.5 4.3-.3 1.5 0 3.1 1 4.3s2.6 1.9 4.2 1.9h.1c1 1.2 2.5 2 4 2s3-.8 4-2h.1c1.6 0 3.2-.7 4.2-1.9s1.3-2.8 1-4.3c.9-1.2 1.1-2.9.5-4.3-.6-1.5-1.8-2.5-3.3-3C17.5 3.8 15 2 12 2z"/></svg>Cortex
603630
</button>
631+
<button class="tab" onclick="showTab('skills')" id="tab-skills">
632+
<svg class="ico ico-sm" viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Skills<span class="tab-badge hidden" id="skillsBadge">0</span>
633+
</button>
604634
<button class="tab" onclick="showTab('settings')" id="tab-settings">
605635
<svg class="ico ico-sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>Settings
606636
</button>
@@ -668,6 +698,22 @@ <h2>MENU</h2>
668698
<iframe id="cortexFrame" src="" style="width:100%;height:calc(100vh - 160px);border:none;border-radius:8px" allowfullscreen></iframe>
669699
</div>
670700

701+
<!-- Skills Review Panel -->
702+
<div class="panel" id="panel-skills">
703+
<div class="skills-review-head">
704+
<div>
705+
<div class="settings-title" style="margin-bottom:0">Skill Reviews</div>
706+
<div class="skills-review-sub">Skills generated by voice or chat are staged here. Review the code, then Approve to activate it or Reject to discard. Nothing runs until you approve.</div>
707+
</div>
708+
<button class="skills-refresh-btn" onclick="loadSkillReviews()" title="Refresh">
709+
<svg class="ico ico-sm" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
710+
</button>
711+
</div>
712+
<div id="skillReviewsList">
713+
<div class="refreshing">Loading staged skills...</div>
714+
</div>
715+
</div>
716+
671717
<!-- Settings Panel -->
672718
<div class="panel" id="panel-settings">
673719
<div class="stat-grid" id="statGrid"></div>
@@ -868,9 +914,114 @@ <h2>MENU</h2>
868914
var cf = document.getElementById('cortexFrame');
869915
if (cf && !cf.src.includes('/cortex')) cf.src = '/cortex?embed=1';
870916
}
917+
if (id === 'skills') loadSkillReviews();
871918
if (id === 'settings') { loadSettings(); renderUIToggles(); loadPrompts(); }
872919
}
873920

921+
// ── Skill Reviews (staged create_skill output → review → approve/reject) ──
922+
var _skillReviewsBusy = false;
923+
function _skillEmptyState() {
924+
return '<div class="skills-empty">' +
925+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>' +
926+
'<div>No skills waiting for review.</div>' +
927+
'<div style="font-size:12px;margin-top:4px;color:var(--text-dim)">Ask CODEC to "create a skill that..." by voice or chat, and it will appear here.</div>' +
928+
'</div>';
929+
}
930+
async function loadSkillReviews() {
931+
var box = document.getElementById('skillReviewsList');
932+
if (!box) return;
933+
box.innerHTML = '<div class="refreshing">Loading staged skills...</div>';
934+
try {
935+
var r = await fetch('/api/skill/reviews');
936+
var d = await r.json();
937+
var reviews = (d && d.reviews) || [];
938+
_setSkillsBadge(reviews.length);
939+
if (!reviews.length) { box.innerHTML = _skillEmptyState(); return; }
940+
box.innerHTML = reviews.map(function(rev) {
941+
var name = _escHtml(rev.filename || rev.id || 'skill.py');
942+
var staged = rev.staged_at ? _escHtml(_fmtStaged(rev.staged_at)) : '';
943+
var meta = staged ? '<span class="skill-review-meta">staged ' + staged + '</span>' : '';
944+
var rid = _escHtml(rev.id || '');
945+
return '<div class="skill-review-card" data-rid="' + rid + '">' +
946+
'<div class="skill-review-top">' +
947+
'<span class="skill-review-name">' + name + ' ' + meta + '</span>' +
948+
'<span class="skill-review-actions">' +
949+
'<button class="skill-btn approve" onclick="approveSkill(this,\'' + rid + '\')">Approve</button>' +
950+
'<button class="skill-btn reject" onclick="rejectSkill(this,\'' + rid + '\')">Reject</button>' +
951+
'</span>' +
952+
'</div>' +
953+
'<pre class="skill-review-code">' + _escHtml(rev.code || '') + '</pre>' +
954+
'</div>';
955+
}).join('');
956+
} catch (e) {
957+
box.innerHTML = '<div class="skills-empty">Could not load staged skills: ' + _escHtml(String(e)) + '</div>';
958+
}
959+
}
960+
function _fmtStaged(iso) {
961+
try {
962+
var dt = new Date(iso);
963+
if (isNaN(dt.getTime())) return iso;
964+
return dt.toLocaleString([], {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
965+
} catch (e) { return iso; }
966+
}
967+
function _setSkillsBadge(n) {
968+
var b = document.getElementById('skillsBadge');
969+
if (!b) return;
970+
b.textContent = n;
971+
b.classList.toggle('hidden', !n);
972+
}
973+
async function refreshSkillsBadge() {
974+
try {
975+
var r = await fetch('/api/skill/reviews');
976+
var d = await r.json();
977+
_setSkillsBadge(((d && d.reviews) || []).length);
978+
} catch (e) { /* leave badge as-is */ }
979+
}
980+
async function approveSkill(btn, rid) {
981+
if (_skillReviewsBusy) return;
982+
_skillReviewsBusy = true;
983+
var card = btn.closest('.skill-review-card');
984+
if (card) card.querySelectorAll('.skill-btn').forEach(function(b){ b.disabled = true; });
985+
try {
986+
var r = await fetch('/api/skill/approve', {
987+
method: 'POST',
988+
headers: {'Content-Type': 'application/json'},
989+
body: JSON.stringify({review_id: rid})
990+
});
991+
var d = await r.json().catch(function(){ return {}; });
992+
if (r.ok) {
993+
showToast('Approved: ' + (d.skill || 'skill') + ' is now active');
994+
} else {
995+
showToast('Approve failed: ' + (d.error || ('HTTP ' + r.status)));
996+
}
997+
} catch (e) {
998+
showToast('Approve failed: ' + String(e));
999+
} finally {
1000+
_skillReviewsBusy = false;
1001+
loadSkillReviews();
1002+
}
1003+
}
1004+
async function rejectSkill(btn, rid) {
1005+
if (_skillReviewsBusy) return;
1006+
_skillReviewsBusy = true;
1007+
var card = btn.closest('.skill-review-card');
1008+
if (card) card.querySelectorAll('.skill-btn').forEach(function(b){ b.disabled = true; });
1009+
try {
1010+
var r = await fetch('/api/skill/reject/' + encodeURIComponent(rid), {method: 'POST'});
1011+
var d = await r.json().catch(function(){ return {}; });
1012+
if (r.ok) {
1013+
showToast('Rejected: ' + (d.filename || 'skill') + ' discarded');
1014+
} else {
1015+
showToast('Reject failed: ' + (d.error || ('HTTP ' + r.status)));
1016+
}
1017+
} catch (e) {
1018+
showToast('Reject failed: ' + String(e));
1019+
} finally {
1020+
_skillReviewsBusy = false;
1021+
loadSkillReviews();
1022+
}
1023+
}
1024+
8741025
var _pendingImage = null; // {base64, name}
8751026

8761027
// ── Send Command ──
@@ -2215,6 +2366,8 @@ <h2>MENU</h2>
22152366
}).catch(function(){})
22162367
}
22172368
pollNotifCount();setInterval(pollNotifCount,30000);
2369+
// Skill-review pending count → tab badge (same cadence as notifications)
2370+
if(typeof refreshSkillsBadge==='function'){refreshSkillsBadge();setInterval(refreshSkillsBadge,30000);}
22182371
var bell=document.getElementById('notifBellBtn');
22192372
if(bell)bell.addEventListener('click',function(){
22202373
fetch('/api/notifications/read-all',{method:'POST'}).catch(function(){});

0 commit comments

Comments
 (0)