|
142 | 142 | .tab.active { color: var(--accent); border-bottom-color: var(--accent); } |
143 | 143 | .tab .ico { opacity: 0.5; } |
144 | 144 | .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; } |
145 | 152 |
|
146 | 153 | /* ── Content ── */ |
147 | 154 | .content { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; } |
|
283 | 290 | .save-config-btn:active { transform: scale(0.98); } |
284 | 291 | .save-config-btn.saved { background: var(--success); } |
285 | 292 |
|
| 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 | + |
286 | 313 | /* ── Prompt Editor ── */ |
287 | 314 | .prompt-card { background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;transition:all .2s } |
288 | 315 | .prompt-card.modified { border-color:var(--accent) } |
@@ -601,6 +628,9 @@ <h2>MENU</h2> |
601 | 628 | <button class="tab" onclick="showTab('cortex')" id="tab-cortex"> |
602 | 629 | <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 |
603 | 630 | </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> |
604 | 634 | <button class="tab" onclick="showTab('settings')" id="tab-settings"> |
605 | 635 | <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 |
606 | 636 | </button> |
@@ -668,6 +698,22 @@ <h2>MENU</h2> |
668 | 698 | <iframe id="cortexFrame" src="" style="width:100%;height:calc(100vh - 160px);border:none;border-radius:8px" allowfullscreen></iframe> |
669 | 699 | </div> |
670 | 700 |
|
| 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 | + |
671 | 717 | <!-- Settings Panel --> |
672 | 718 | <div class="panel" id="panel-settings"> |
673 | 719 | <div class="stat-grid" id="statGrid"></div> |
@@ -868,9 +914,114 @@ <h2>MENU</h2> |
868 | 914 | var cf = document.getElementById('cortexFrame'); |
869 | 915 | if (cf && !cf.src.includes('/cortex')) cf.src = '/cortex?embed=1'; |
870 | 916 | } |
| 917 | + if (id === 'skills') loadSkillReviews(); |
871 | 918 | if (id === 'settings') { loadSettings(); renderUIToggles(); loadPrompts(); } |
872 | 919 | } |
873 | 920 |
|
| 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 | + |
874 | 1025 | var _pendingImage = null; // {base64, name} |
875 | 1026 |
|
876 | 1027 | // ── Send Command ── |
@@ -2215,6 +2366,8 @@ <h2>MENU</h2> |
2215 | 2366 | }).catch(function(){}) |
2216 | 2367 | } |
2217 | 2368 | pollNotifCount();setInterval(pollNotifCount,30000); |
| 2369 | + // Skill-review pending count → tab badge (same cadence as notifications) |
| 2370 | + if(typeof refreshSkillsBadge==='function'){refreshSkillsBadge();setInterval(refreshSkillsBadge,30000);} |
2218 | 2371 | var bell=document.getElementById('notifBellBtn'); |
2219 | 2372 | if(bell)bell.addEventListener('click',function(){ |
2220 | 2373 | fetch('/api/notifications/read-all',{method:'POST'}).catch(function(){}); |
|
0 commit comments