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:
2026-04-08 17:38:29 +00:00
parent 6a62edbdb4
commit 2d265d2f0e
2 changed files with 448 additions and 0 deletions

View File

@@ -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">&#128268;</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">&#128268;</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 &middot; ${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>