diff --git a/pkg/nodes/registry_server.go b/pkg/nodes/registry_server.go index b163e24..0f0e16c 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/nodes/registry_server.go @@ -583,8 +583,69 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques } _ = os.MkdirAll(skillsDir, 0755) + resolveSkillPath := func(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name required") + } + cands := []string{ + filepath.Join(skillsDir, name), + filepath.Join(skillsDir, name+".disabled"), + filepath.Join("/root/clawgo/workspace/skills", name), + filepath.Join("/root/clawgo/workspace/skills", name+".disabled"), + } + for _, p := range cands { + if st, err := os.Stat(p); err == nil && st.IsDir() { + return p, nil + } + } + return "", fmt.Errorf("skill not found: %s", name) + } + switch r.Method { case http.MethodGet: + if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" { + skillPath, err := resolveSkillPath(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if strings.TrimSpace(r.URL.Query().Get("files")) == "1" { + var files []string + _ = filepath.WalkDir(skillPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + rel, _ := filepath.Rel(skillPath, path) + if strings.HasPrefix(rel, "..") { + return nil + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": id, "files": files}) + return + } + if f := strings.TrimSpace(r.URL.Query().Get("file")); f != "" { + clean := filepath.Clean(f) + if strings.HasPrefix(clean, "..") { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + full := filepath.Join(skillPath, clean) + b, err := os.ReadFile(full) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "id": id, "file": filepath.ToSlash(clean), "content": string(b)}) + return + } + } + type skillItem struct { ID string `json:"id"` Name string `json:"name"` @@ -709,6 +770,29 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques } } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + case "write_file": + skillPath, err := resolveSkillPath(name) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + filePath, _ := body["file"].(string) + clean := filepath.Clean(strings.TrimSpace(filePath)) + if clean == "" || strings.HasPrefix(clean, "..") { + http.Error(w, "invalid file path", http.StatusBadRequest) + return + } + content, _ := body["content"].(string) + full := filepath.Join(skillPath, clean) + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := os.WriteFile(full, []byte(content), 0644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "name": name, "file": filepath.ToSlash(clean)}) case "create", "update": desc, _ := body["description"].(string) sys, _ := body["system_prompt"].(string) diff --git a/webui/src/pages/Skills.tsx b/webui/src/pages/Skills.tsx index de86184..e5fa31a 100644 --- a/webui/src/pages/Skills.tsx +++ b/webui/src/pages/Skills.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Plus, RefreshCw, Trash2, Edit2, Zap, X, Code } from 'lucide-react'; +import { Plus, RefreshCw, Trash2, Edit2, Zap, X, FileText, Save } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; @@ -21,6 +21,12 @@ const Skills: React.FC = () => { const [editingSkill, setEditingSkill] = useState(null); const [form, setForm] = useState>(initialSkillForm); + const [isFileModalOpen, setIsFileModalOpen] = useState(false); + const [activeSkill, setActiveSkill] = useState(''); + const [skillFiles, setSkillFiles] = useState([]); + const [activeFile, setActiveFile] = useState(''); + const [fileContent, setFileContent] = useState(''); + async function deleteSkill(id: string) { if (!confirm('Are you sure you want to delete this skill?')) return; try { @@ -55,7 +61,7 @@ const Skills: React.FC = () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...(editingSkill && { id: editingSkill.id }), ...form }) }); - + if (r.ok) { setIsModalOpen(false); await refreshSkills(); @@ -63,13 +69,58 @@ const Skills: React.FC = () => { alert(await r.text()); } } catch (e) { - alert(e); + alert(String(e)); } } + 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) { + alert(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) { + alert(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) { + alert(await r.text()); + return; + } + alert('Saved'); + } + return (
-
+

{t('skills')}

setInstallName(e.target.value)} placeholder="skill name" className="px-3 py-2 bg-zinc-950 border border-zinc-800 rounded-lg text-sm" /> @@ -99,7 +150,7 @@ const Skills: React.FC = () => {
- +

{s.description || 'No description provided.'}

@@ -112,33 +163,32 @@ const Skills: React.FC = () => { {(!Array.isArray(s.tools) || s.tools.length === 0) && No tools defined}
- {s.system_prompt && ( -
-
System Prompt
-
- {s.system_prompt} -
-
- )}
- - +
))} - {skills.length === 0 && ( -
- -

No skills defined

-
- )} @@ -167,30 +211,24 @@ const Skills: React.FC = () => {