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:
248
web/app.py
248
web/app.py
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -17,11 +18,15 @@ from uuid import UUID, uuid4
|
||||
# Allow importing legal_mcp from the MCP server source
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
|
||||
|
||||
import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
import asyncpg
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, processor
|
||||
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools
|
||||
@@ -774,6 +779,249 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest):
|
||||
return project
|
||||
|
||||
|
||||
# ── Skill Management API ───────────────────────────────────────────
|
||||
|
||||
|
||||
PAPERCLIP_DB_URL = os.environ.get(
|
||||
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
||||
)
|
||||
# In Docker: mounted at /paperclip-skills; locally: ~/.paperclip/instances/default/skills
|
||||
_docker_skills = Path("/paperclip-skills")
|
||||
_local_skills = Path.home() / ".paperclip" / "instances" / "default" / "skills"
|
||||
PAPERCLIP_SKILLS_DIR = _docker_skills if _docker_skills.exists() else _local_skills
|
||||
# Default company ID for skills
|
||||
SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294")
|
||||
|
||||
|
||||
@app.get("/api/admin/skills")
|
||||
async def api_list_skills():
|
||||
"""List installed Paperclip skills with DB sync status."""
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
||||
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
|
||||
SKILLS_COMPANY_ID,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
skills = []
|
||||
for r in rows:
|
||||
slug = r["slug"]
|
||||
skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug
|
||||
disk_exists = skill_dir.exists()
|
||||
disk_skill_md = None
|
||||
if disk_exists:
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if skill_md.exists():
|
||||
disk_skill_md = skill_md.stat().st_size
|
||||
|
||||
skills.append({
|
||||
"slug": slug,
|
||||
"name": r["name"],
|
||||
"db_markdown_chars": r["md_chars"],
|
||||
"file_inventory": json.loads(r["file_inventory"]) if isinstance(r["file_inventory"], str) else r["file_inventory"],
|
||||
"updated_at": r["updated_at"].isoformat() if r["updated_at"] else None,
|
||||
"disk_exists": disk_exists,
|
||||
"disk_skill_md_bytes": disk_skill_md,
|
||||
})
|
||||
|
||||
# Also check for skills on disk that aren't in DB
|
||||
company_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID
|
||||
if company_dir.exists():
|
||||
db_slugs = {s["slug"] for s in skills}
|
||||
for d in sorted(company_dir.iterdir()):
|
||||
if d.is_dir() and d.name not in db_slugs:
|
||||
skill_md = d / "SKILL.md"
|
||||
skills.append({
|
||||
"slug": d.name,
|
||||
"name": d.name,
|
||||
"db_markdown_chars": 0,
|
||||
"file_inventory": [],
|
||||
"updated_at": None,
|
||||
"disk_exists": True,
|
||||
"disk_skill_md_bytes": skill_md.stat().st_size if skill_md.exists() else None,
|
||||
"not_in_db": True,
|
||||
})
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
@app.post("/api/admin/skills/install")
|
||||
async def api_install_skill(file: UploadFile = File(...)):
|
||||
"""Install or update a Paperclip skill from a ZIP file.
|
||||
|
||||
The ZIP should contain a SKILL.md at root (or in a single subdirectory).
|
||||
The skill slug is derived from the directory name or ZIP filename.
|
||||
"""
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "No filename provided")
|
||||
|
||||
if not file.filename.lower().endswith(".zip"):
|
||||
raise HTTPException(400, "Only ZIP files are supported")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 100 * 1024 * 1024: # 100MB limit
|
||||
raise HTTPException(400, "File too large (max 100MB)")
|
||||
|
||||
import io
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid ZIP file")
|
||||
|
||||
# Find SKILL.md and determine the skill root
|
||||
skill_md_path = None
|
||||
skill_root = ""
|
||||
names = zf.namelist()
|
||||
|
||||
for name in names:
|
||||
basename = name.split("/")[-1]
|
||||
if basename == "SKILL.md":
|
||||
skill_md_path = name
|
||||
# Root is everything before SKILL.md
|
||||
skill_root = name[: -len("SKILL.md")]
|
||||
break
|
||||
|
||||
if not skill_md_path:
|
||||
zf.close()
|
||||
raise HTTPException(400, "ZIP must contain a SKILL.md file")
|
||||
|
||||
# Determine slug: from directory name in ZIP, or from ZIP filename
|
||||
if skill_root and skill_root.strip("/"):
|
||||
slug = skill_root.strip("/").split("/")[0]
|
||||
else:
|
||||
slug = Path(file.filename).stem.lower()
|
||||
slug = re.sub(r"[^\w\-]", "-", slug).strip("-")
|
||||
|
||||
# Extract to skill directory
|
||||
skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clear existing contents
|
||||
for item in skill_dir.rglob("*"):
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
# Remove empty subdirs
|
||||
for item in sorted(skill_dir.rglob("*"), reverse=True):
|
||||
if item.is_dir():
|
||||
try:
|
||||
item.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Extract files, stripping the skill_root prefix
|
||||
extracted_files = []
|
||||
for name in names:
|
||||
if name.endswith("/"):
|
||||
continue # skip directories
|
||||
if not name.startswith(skill_root):
|
||||
continue # skip files outside skill root
|
||||
|
||||
rel_path = name[len(skill_root):]
|
||||
if not rel_path:
|
||||
continue
|
||||
# Skip macOS metadata
|
||||
if "/__MACOSX/" in name or rel_path.startswith("__MACOSX/") or rel_path.startswith("."):
|
||||
continue
|
||||
|
||||
dest = skill_dir / rel_path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(zf.read(name))
|
||||
extracted_files.append(rel_path)
|
||||
|
||||
zf.close()
|
||||
|
||||
# Read SKILL.md content
|
||||
skill_md_file = skill_dir / "SKILL.md"
|
||||
if not skill_md_file.exists():
|
||||
raise HTTPException(500, "SKILL.md was not extracted properly")
|
||||
|
||||
markdown_content = skill_md_file.read_text(encoding="utf-8")
|
||||
|
||||
# Build file_inventory
|
||||
file_inventory = []
|
||||
for rel in sorted(extracted_files):
|
||||
if rel == "SKILL.md":
|
||||
kind = "skill"
|
||||
elif rel.startswith("scripts/"):
|
||||
kind = "script"
|
||||
elif rel.startswith("references/"):
|
||||
kind = "reference"
|
||||
elif rel.endswith(".zip"):
|
||||
kind = "archive"
|
||||
else:
|
||||
kind = "resource"
|
||||
file_inventory.append({"kind": kind, "path": rel})
|
||||
|
||||
# Update DB
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
existing = await conn.fetchval(
|
||||
"SELECT id FROM company_skills WHERE company_id = $1::uuid AND slug = $2",
|
||||
SKILLS_COMPANY_ID, slug,
|
||||
)
|
||||
if existing:
|
||||
await conn.execute(
|
||||
"""UPDATE company_skills
|
||||
SET markdown = $1, file_inventory = $2::jsonb, updated_at = now()
|
||||
WHERE id = $3""",
|
||||
markdown_content,
|
||||
json.dumps(file_inventory, ensure_ascii=False),
|
||||
existing,
|
||||
)
|
||||
action = "updated"
|
||||
else:
|
||||
await conn.execute(
|
||||
"""INSERT INTO company_skills
|
||||
(company_id, key, slug, name, markdown, source_type, file_inventory)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, 'local_path', $6::jsonb)""",
|
||||
SKILLS_COMPANY_ID, slug, slug, slug,
|
||||
markdown_content,
|
||||
json.dumps(file_inventory, ensure_ascii=False),
|
||||
)
|
||||
action = "installed"
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"action": action,
|
||||
"files_extracted": len(extracted_files),
|
||||
"file_inventory": file_inventory,
|
||||
"markdown_chars": len(markdown_content),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/paperclip/restart")
|
||||
async def api_restart_paperclip():
|
||||
"""Restart the Paperclip PM2 process.
|
||||
|
||||
Tries pm2 directly (works when running locally on the host).
|
||||
In Docker, writes a restart flag file that the host watcher picks up.
|
||||
"""
|
||||
# Try pm2 directly (works when running outside Docker)
|
||||
result = subprocess.run(
|
||||
["pm2", "restart", "paperclip"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return {"status": "restarted", "method": "pm2", "output": result.stdout.strip()}
|
||||
|
||||
# Fallback: write a flag file that host-side watcher picks up
|
||||
flag_file = PAPERCLIP_SKILLS_DIR / ".restart-requested"
|
||||
try:
|
||||
flag_file.write_text(str(time.time()))
|
||||
return {
|
||||
"status": "restart_requested",
|
||||
"method": "flag_file",
|
||||
"message": "Restart requested — the host watcher will restart Paperclip shortly.",
|
||||
}
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot restart Paperclip from Docker. Run manually: pm2 restart paperclip")
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/documents/upload-tagged")
|
||||
async def api_upload_tagged_document(
|
||||
case_number: str,
|
||||
|
||||
@@ -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