Add Paperclip skill install/upgrade UI and API
- POST /api/admin/skills/install — upload ZIP, extract to skills dir, update DB - GET /api/admin/skills — list installed skills with DB/disk sync status - POST /api/admin/paperclip/restart — restart Paperclip (pm2 or flag file) - New Skills page in web UI with drag-and-drop ZIP upload - Coolify volume mount for /paperclip-skills - Host-side crontab watcher for restart flag file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -241,6 +241,27 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
|
||||
|
||||
.empty { text-align: center; color: #bbb; padding: 40px 20px; font-size: 0.88em; line-height: 1.6; }
|
||||
|
||||
/* ── Skills Management ───────────────────────────────── */
|
||||
.skill-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.skill-item {
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px 18px;
|
||||
border: 1px solid #eee; border-radius: 8px; background: #fafafa;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.skill-item:hover { background: #f0f0f0; }
|
||||
.skill-item .skill-icon { font-size: 1.5em; flex-shrink: 0; }
|
||||
.skill-item .skill-info { flex: 1; min-width: 0; }
|
||||
.skill-item .skill-name { font-size: 0.95em; font-weight: 600; }
|
||||
.skill-item .skill-meta { font-size: 0.75em; color: #999; margin-top: 3px; display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.skill-item .skill-badges { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.skill-item .badge-ok { background: #e8f5e9; color: #388e3c; padding: 2px 8px; border-radius: 4px; font-size: 0.72em; font-weight: 600; }
|
||||
.skill-item .badge-warn { background: #fff3e0; color: #f57c00; padding: 2px 8px; border-radius: 4px; font-size: 0.72em; font-weight: 600; }
|
||||
.skill-install-result {
|
||||
margin-top: 16px; padding: 16px; border-radius: 8px;
|
||||
background: #e8f5e9; border: 1px solid #c8e6c9; font-size: 0.88em;
|
||||
}
|
||||
.skill-install-result.error { background: #ffebee; border-color: #ffcdd2; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.main { padding: 16px; }
|
||||
header { padding: 14px 16px; }
|
||||
@@ -260,6 +281,7 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
|
||||
<a href="#/" id="navHome">תיקים</a>
|
||||
<a href="#/new" id="navNew">+ תיק חדש</a>
|
||||
<a href="#/upload" id="navUpload">העלאה</a>
|
||||
<a href="#/skills" id="navSkills">Skills</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -442,6 +464,46 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Skills Management ══ -->
|
||||
<div class="page" id="page-skills">
|
||||
<div class="page-header">
|
||||
<h2>Paperclip Skills</h2>
|
||||
</div>
|
||||
|
||||
<!-- Install Skill -->
|
||||
<div class="card">
|
||||
<div class="card-header">התקנה / שדרוג Skill</div>
|
||||
<div class="card-body">
|
||||
<div class="upload-zone" id="skillDropZone">
|
||||
<div style="font-size:3em;color:#ccc;margin-bottom:16px">🔌</div>
|
||||
<h3>גרור קובץ ZIP של Skill או לחץ לבחירה</h3>
|
||||
<p>ZIP עם SKILL.md, scripts/, references/ — לפי מבנה Anthropic</p>
|
||||
<input type="file" id="skillFileInput" accept=".zip">
|
||||
</div>
|
||||
<div id="skillInstallResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Skills -->
|
||||
<div class="card">
|
||||
<div class="card-header">Skills מותקנים</div>
|
||||
<div class="card-body">
|
||||
<div class="skill-list" id="skillList">
|
||||
<div class="empty">טוען...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Paperclip -->
|
||||
<div class="card">
|
||||
<div class="card-header">Paperclip Server</div>
|
||||
<div class="card-body" style="display:flex;align-items:center;gap:16px">
|
||||
<button class="btn btn-secondary" id="restartPaperclipBtn" onclick="restartPaperclip()">Restart Paperclip</button>
|
||||
<span id="restartStatus" style="font-size:0.85em;color:#888"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Legacy Upload ══ -->
|
||||
<div class="page" id="page-upload">
|
||||
<div class="card"><div class="card-body">
|
||||
@@ -506,6 +568,12 @@ function handleRoute() {
|
||||
subtitle = 'ערר ' + caseNum;
|
||||
currentCaseNumber = caseNum;
|
||||
loadCaseView(caseNum);
|
||||
} else if (hash === '#/skills') {
|
||||
document.getElementById('page-skills').classList.add('active');
|
||||
document.getElementById('navSkills').classList.add('active');
|
||||
subtitle = 'Paperclip Skills';
|
||||
loadSkillList();
|
||||
setupSkillUpload();
|
||||
} else if (hash === '#/upload') {
|
||||
document.getElementById('page-upload').classList.add('active');
|
||||
document.getElementById('navUpload').classList.add('active');
|
||||
@@ -1210,6 +1278,138 @@ function toast(msg, type = '') {
|
||||
setTimeout(() => el.className = 'toast', 3000);
|
||||
}
|
||||
|
||||
// ── Skills Management ───────────────────────────────────
|
||||
async function loadSkillList() {
|
||||
const list = document.getElementById('skillList');
|
||||
try {
|
||||
const res = await fetch(API + '/admin/skills');
|
||||
const skills = await res.json();
|
||||
if (!skills.length) {
|
||||
list.innerHTML = '<div class="empty">אין skills מותקנים</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = skills.map(s => {
|
||||
const fileCount = s.file_inventory ? s.file_inventory.length : 0;
|
||||
const updatedStr = s.updated_at ? new Date(s.updated_at).toLocaleDateString('he-IL') : '—';
|
||||
const inDb = s.db_markdown_chars > 0;
|
||||
const onDisk = s.disk_exists;
|
||||
return `
|
||||
<div class="skill-item">
|
||||
<span class="skill-icon">🔌</span>
|
||||
<div class="skill-info">
|
||||
<div class="skill-name">${esc(s.name)}</div>
|
||||
<div class="skill-meta">
|
||||
<span>${fileCount} files</span>
|
||||
<span>${s.db_markdown_chars.toLocaleString()} chars</span>
|
||||
<span>Updated: ${updatedStr}</span>
|
||||
${s.disk_skill_md_bytes ? `<span>Disk: ${formatSize(s.disk_skill_md_bytes)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-badges">
|
||||
${inDb ? '<span class="badge-ok">DB</span>' : '<span class="badge-warn">No DB</span>'}
|
||||
${onDisk ? '<span class="badge-ok">Disk</span>' : '<span class="badge-warn">No Disk</span>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="empty">שגיאה בטעינת skills</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function setupSkillUpload() {
|
||||
const dropZone = document.getElementById('skillDropZone');
|
||||
const fileInput = document.getElementById('skillFileInput');
|
||||
|
||||
const newDrop = dropZone.cloneNode(true);
|
||||
dropZone.parentNode.replaceChild(newDrop, dropZone);
|
||||
const newInput = newDrop.querySelector('input[type="file"]');
|
||||
|
||||
newDrop.addEventListener('click', () => newInput.click());
|
||||
newDrop.addEventListener('dragover', e => { e.preventDefault(); newDrop.classList.add('dragover'); });
|
||||
newDrop.addEventListener('dragleave', () => newDrop.classList.remove('dragover'));
|
||||
newDrop.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
newDrop.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length) installSkill(e.dataTransfer.files[0]);
|
||||
});
|
||||
newInput.addEventListener('change', () => {
|
||||
if (newInput.files.length) installSkill(newInput.files[0]);
|
||||
newInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
async function installSkill(file) {
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
return toast('Only ZIP files are supported', 'error');
|
||||
}
|
||||
|
||||
const resultDiv = document.getElementById('skillInstallResult');
|
||||
resultDiv.innerHTML = '<div class="skill-install-result">Installing...</div>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/admin/skills/install', {
|
||||
method: 'POST', body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
resultDiv.innerHTML = `<div class="skill-install-result error">${esc(data.detail || 'Installation failed')}</div>`;
|
||||
toast('Installation failed', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionLabel = data.action === 'updated' ? 'Updated' : 'Installed';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="skill-install-result">
|
||||
<b>${actionLabel}: ${esc(data.slug)}</b><br>
|
||||
${data.files_extracted} files extracted · ${data.markdown_chars.toLocaleString()} chars<br><br>
|
||||
<button class="btn btn-primary" onclick="confirmRestart()">Restart Paperclip</button>
|
||||
<span style="font-size:0.82em;color:#888;margin-right:12px">Restart required to apply changes</span>
|
||||
</div>`;
|
||||
toast(actionLabel + ': ' + data.slug, 'success');
|
||||
loadSkillList();
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = `<div class="skill-install-result error">Network error</div>`;
|
||||
toast('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRestart() {
|
||||
if (!confirm('Restart Paperclip?')) return;
|
||||
restartPaperclip();
|
||||
}
|
||||
|
||||
async function restartPaperclip() {
|
||||
const btn = document.getElementById('restartPaperclipBtn');
|
||||
const status = document.getElementById('restartStatus');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Restarting...';
|
||||
status.textContent = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/admin/paperclip/restart', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
status.textContent = 'Error: ' + (data.detail || 'failed');
|
||||
status.style.color = '#e94560';
|
||||
toast('Restart failed', 'error');
|
||||
} else {
|
||||
status.textContent = 'Restarted successfully';
|
||||
status.style.color = '#27ae60';
|
||||
toast('Paperclip restarted', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = 'Network error';
|
||||
status.style.color = '#e94560';
|
||||
toast('Network error', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Restart Paperclip';
|
||||
}
|
||||
}
|
||||
|
||||
// Init legacy upload listeners
|
||||
setupLegacyUpload();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user