import React, { useRef, useState } from 'react'; import { Plus, RefreshCw, Trash2, Zap, X, FileText, Save } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import { useUI } from '../context/UIContext'; const Skills: React.FC = () => { const { t } = useTranslation(); const { skills, refreshSkills, q, clawhubInstalled, clawhubPath } = useAppContext(); const ui = useUI(); const [installName, setInstallName] = useState(''); const [installingSkill, setInstallingSkill] = useState(false); const [ignoreSuspicious, setIgnoreSuspicious] = useState(false); const qp = (k: string, v: string) => `${q}${q ? '&' : '?'}${k}=${encodeURIComponent(v)}`; const [isFileModalOpen, setIsFileModalOpen] = useState(false); const [activeSkill, setActiveSkill] = useState(''); const [skillFiles, setSkillFiles] = useState([]); const [activeFile, setActiveFile] = useState(''); const [fileContent, setFileContent] = useState(''); const uploadRef = useRef(null); async function deleteSkill(id: string) { if (!await ui.confirmDialog({ title: t('skillsDeleteTitle'), message: t('skillsDeleteMessage'), danger: true, confirmText: t('delete') })) return; try { await fetch(`/webui/api/skills${qp('id', id)}`, { method: 'DELETE' }); await refreshSkills(); } catch (e) { console.error(e); } } async function installClawHubIfNeeded() { if (clawhubInstalled) return true; const confirm = await ui.confirmDialog({ title: t('skillsClawhubMissingTitle'), message: t('skillsClawhubMissingMessage'), confirmText: t('skillsInstallNow') }); if (!confirm) return false; ui.showLoading(t('skillsInstallingDeps')); try { const r = await fetch(`/webui/api/skills${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'install_clawhub' }), }); const text = await r.text(); if (!r.ok) { ui.hideLoading(); await ui.notify({ title: t('skillsInstallFailedTitle'), message: text || t('skillsInstallFailedMessage') }); return false; } ui.hideLoading(); await ui.notify({ title: t('skillsInstallDoneTitle'), message: t('skillsInstallDoneMessage') }); await refreshSkills(); return true; } finally { // loading is explicitly closed before notify, keep this as fallback. ui.hideLoading(); } } async function installSkill() { if (installingSkill) return; const name = installName.trim(); if (!name) return; setInstallingSkill(true); ui.showLoading(t('skillsInstallingSkill')); try { const ready = await installClawHubIfNeeded(); if (!ready) return; const r = await fetch(`/webui/api/skills${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'install', name, ignore_suspicious: ignoreSuspicious }), }); if (!r.ok) { ui.hideLoading(); await ui.notify({ title: t('requestFailed'), message: await r.text() }); return; } setInstallName(''); await refreshSkills(); ui.hideLoading(); await ui.notify({ title: t('skillsInstallSkillDoneTitle'), message: t('skillsInstallSkillDoneMessage', { name }) }); } finally { ui.hideLoading(); setInstallingSkill(false); } } async function onAddSkillClick() { const yes = await ui.confirmDialog({ title: t('skillsAddTitle'), message: t('skillsAddMessage'), confirmText: t('skillsSelectArchive') }); if (!yes) return; uploadRef.current?.click(); } async function onArchiveSelected(e: React.ChangeEvent) { const f = e.target.files?.[0]; e.target.value = ''; if (!f) return; const fd = new FormData(); fd.append('file', f); ui.showLoading(t('skillsImporting')); try { const r = await fetch(`/webui/api/skills${q}`, { method: 'POST', body: fd, }); const text = await r.text(); if (!r.ok) { await ui.notify({ title: t('skillsImportFailedTitle'), message: text || t('skillsImportFailedMessage') }); return; } let imported: string[] = []; try { const j = JSON.parse(text); imported = Array.isArray(j.imported) ? j.imported : []; } catch { imported = []; } await ui.notify({ title: t('skillsImportDoneTitle'), message: imported.length > 0 ? `${t('skillsImportedPrefix')}: ${imported.join(', ')}` : t('skillsImportDoneMessage') }); await refreshSkills(); } finally { ui.hideLoading(); } } async function openFileManager(skillId: string) { setActiveSkill(skillId); setIsFileModalOpen(true); const r = await fetch(`/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&files=1` : `?id=${encodeURIComponent(skillId)}&files=1`}`); if (!r.ok) { await ui.notify({ title: t('requestFailed'), message: await r.text() }); return; } const j = await r.json(); const files = Array.isArray(j.files) ? j.files : []; setSkillFiles(files); if (files.length > 0) { await openFile(skillId, files[0]); } else { setActiveFile(''); setFileContent(''); } } async function openFile(skillId: string, file: string) { const url = `/webui/api/skills${q ? `${q}&id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}` : `?id=${encodeURIComponent(skillId)}&file=${encodeURIComponent(file)}`}`; const r = await fetch(url); if (!r.ok) { await ui.notify({ title: t('requestFailed'), message: await r.text() }); return; } const j = await r.json(); setActiveFile(file); setFileContent(String(j.content || '')); } async function saveFile() { if (!activeSkill || !activeFile) return; const r = await fetch(`/webui/api/skills${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'write_file', id: activeSkill, file: activeFile, content: fileContent }), }); if (!r.ok) { await ui.notify({ title: t('requestFailed'), message: await r.text() }); return; } await ui.notify({ title: t('saved'), message: t('skillsFileSaved') }); } return (

{t('skills')}

setInstallName(e.target.value)} placeholder={t('skillsNamePlaceholder')} className="px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-lg text-sm disabled:opacity-60" />
{t('skillsClawhubStatus')}: {clawhubInstalled ? t('installed') : t('notInstalled')}
{skills.map(s => (

{s.name}

{t('id')}: {s.id.slice(-6)}

{s.description || t('noDescription')}

{t('tools')}
{(Array.isArray(s.tools) ? s.tools : []).map(tool => ( {tool} ))} {(!Array.isArray(s.tools) || s.tools.length === 0) && {t('skillsNoTools')}}
))}
{isFileModalOpen && (
setIsFileModalOpen(false)} className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{activeFile || t('noFileSelected')}