From c1962e4659e9a8f3f48fa4b19eb0bbe4ef2d7148 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 8 Mar 2026 00:16:33 +0700 Subject: [PATCH] feat(skills): add edit modal, drag-and-drop upload, fix frontmatter stripping - Add skill edit dialog (name, description, visibility, tags) - Add drag-and-drop support to upload modal with visual feedback - Fix frontmatter not stripped in skill detail view (CRLF normalization) - Include visibility, tags, version in skill list/detail API responses - Change default skill upload visibility from private to internal - Add visibility column and version display to skills table --- internal/gateway/methods/skills.go | 23 ++- internal/http/skills.go | 2 +- internal/skills/loader.go | 12 +- internal/store/pg/skills.go | 37 ++++- internal/store/skill_store.go | 17 +- ui/web/src/pages/skills/hooks/use-skills.ts | 11 +- .../src/pages/skills/skill-detail-dialog.tsx | 15 +- ui/web/src/pages/skills/skill-edit-dialog.tsx | 157 ++++++++++++++++++ .../src/pages/skills/skill-upload-dialog.tsx | 30 +++- ui/web/src/pages/skills/skills-page.tsx | 64 +++++-- ui/web/src/types/skill.ts | 3 + 11 files changed, 334 insertions(+), 37 deletions(-) create mode 100644 ui/web/src/pages/skills/skill-edit-dialog.tsx diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index 056706da..313fe816 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -36,10 +36,17 @@ func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, re "slug": s.Slug, "description": s.Description, "source": s.Source, + "version": s.Version, } if s.ID != "" { entry["id"] = s.ID } + if s.Visibility != "" { + entry["visibility"] = s.Visibility + } + if len(s.Tags) > 0 { + entry["tags"] = s.Tags + } result = append(result, entry) } @@ -68,12 +75,24 @@ func (m *SkillsMethods) handleGet(_ context.Context, client *gateway.Client, req content, _ := m.store.LoadSkill(params.Name) - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + resp := map[string]interface{}{ "name": info.Name, + "slug": info.Slug, "description": info.Description, "source": info.Source, "content": content, - })) + "version": info.Version, + } + if info.ID != "" { + resp["id"] = info.ID + } + if info.Visibility != "" { + resp["visibility"] = info.Visibility + } + if len(info.Tags) > 0 { + resp["tags"] = info.Tags + } + client.SendResponse(protocol.NewOKResponse(req.ID, resp)) } // skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore). diff --git a/internal/http/skills.go b/internal/http/skills.go index 2bfcb127..3f083ff5 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -270,7 +270,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { Slug: slug, Description: &desc, OwnerID: userID, - Visibility: "private", + Visibility: "internal", Version: version, FilePath: destDir, FileSize: size, diff --git a/internal/skills/loader.go b/internal/skills/loader.go index 267aaa59..5095d1b8 100644 --- a/internal/skills/loader.go +++ b/internal/skills/loader.go @@ -441,8 +441,16 @@ func parseMetadata(path string) *Metadata { } } +// normalizeLineEndings converts \r\n and bare \r to \n so frontmatter regex matches +// files created on Windows or uploaded via ZIP with CRLF line endings. +func normalizeLineEndings(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return s +} + func extractFrontmatter(content string) string { - match := frontmatterRe.FindStringSubmatch(content) + match := frontmatterRe.FindStringSubmatch(normalizeLineEndings(content)) if len(match) > 1 { return match[1] } @@ -450,7 +458,7 @@ func extractFrontmatter(content string) string { } func stripFrontmatter(content string) string { - return frontmatterRe.ReplaceAllString(content, "") + return frontmatterRe.ReplaceAllString(normalizeLineEndings(content), "") } func parseSimpleYAML(content string) map[string]string { diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go index f54dbfb3..ff7d8e8e 100644 --- a/internal/store/pg/skills.go +++ b/internal/store/pg/skills.go @@ -7,11 +7,14 @@ import ( "fmt" "log/slog" "os" + "regexp" + "strings" "sync" "sync/atomic" "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -61,7 +64,7 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { // Cache miss or TTL expired → query DB rows, err := s.db.Query( - `SELECT id, name, slug, description, version FROM skills WHERE status = 'active' ORDER BY name`) + `SELECT id, name, slug, description, visibility, tags, version FROM skills WHERE status = 'active' ORDER BY name`) if err != nil { return nil } @@ -70,13 +73,17 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { var result []store.SkillInfo for rows.Next() { var id uuid.UUID - var name, slug string + var name, slug, visibility string var desc *string + var tags []string var version int - if err := rows.Scan(&id, &name, &slug, &desc, &version); err != nil { + if err := rows.Scan(&id, &name, &slug, &desc, &visibility, pq.Array(&tags), &version); err != nil { continue } - result = append(result, buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir)) + info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir) + info.Visibility = visibility + info.Tags = tags + result = append(result, info) } s.mu.Lock() @@ -148,16 +155,20 @@ func (s *PGSkillStore) BuildSummary(allowList []string) string { } func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { - var skillName, slug string + var id uuid.UUID + var skillName, slug, visibility string var desc *string + var tags []string var version int err := s.db.QueryRow( - "SELECT name, slug, description, version FROM skills WHERE slug = $1 AND status = 'active'", name, - ).Scan(&skillName, &slug, &desc, &version) + "SELECT id, name, slug, description, visibility, tags, version FROM skills WHERE slug = $1 AND status = 'active'", name, + ).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version) if err != nil { return nil, false } - info := buildSkillInfo("", skillName, slug, desc, version, s.baseDir) + info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir) + info.Visibility = visibility + info.Tags = tags return &info, true } @@ -439,14 +450,22 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st BaseDir: fmt.Sprintf("%s/%s/%d", baseDir, slug, version), Source: "managed", Description: d, + Version: version, } } +// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file. +var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`) + func readSkillContent(baseDir, slug string, version int) (string, error) { path := fmt.Sprintf("%s/%s/%d/SKILL.md", baseDir, slug, version) data, err := os.ReadFile(path) if err != nil { return "", err } - return string(data), nil + // Normalize line endings (Windows CRLF → LF) and strip frontmatter + content := strings.ReplaceAll(string(data), "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + content = skillFrontmatterRe.ReplaceAllString(content, "") + return content, nil } diff --git a/internal/store/skill_store.go b/internal/store/skill_store.go index 2f5e0ef3..a41b89f5 100644 --- a/internal/store/skill_store.go +++ b/internal/store/skill_store.go @@ -8,13 +8,16 @@ import ( // SkillInfo describes a discovered skill. type SkillInfo struct { - ID string `json:"id,omitempty"` // DB UUID - Name string `json:"name"` - Slug string `json:"slug"` - Path string `json:"path"` - BaseDir string `json:"baseDir"` - Source string `json:"source"` - Description string `json:"description"` + ID string `json:"id,omitempty"` // DB UUID + Name string `json:"name"` + Slug string `json:"slug"` + Path string `json:"path"` + BaseDir string `json:"baseDir"` + Source string `json:"source"` + Description string `json:"description"` + Visibility string `json:"visibility,omitempty"` + Tags []string `json:"tags,omitempty"` + Version int `json:"version,omitempty"` } // SkillSearchResult is a scored skill returned from embedding search. diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index 7a7e2913..d6178b40 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -51,6 +51,15 @@ export function useSkills() { [http, invalidate], ); + const updateSkill = useCallback( + async (id: string, updates: Record) => { + const res = await http.put<{ ok: string }>(`/v1/skills/${id}`, updates); + await invalidate(); + return res; + }, + [http, invalidate], + ); + const deleteSkill = useCallback( async (id: string) => { const res = await http.delete<{ ok: string }>(`/v1/skills/${id}`); @@ -60,5 +69,5 @@ export function useSkills() { [http, invalidate], ); - return { skills, loading, refresh: invalidate, getSkill, uploadSkill, deleteSkill }; + return { skills, loading, refresh: invalidate, getSkill, uploadSkill, updateSkill, deleteSkill }; } diff --git a/ui/web/src/pages/skills/skill-detail-dialog.tsx b/ui/web/src/pages/skills/skill-detail-dialog.tsx index 65b34d54..776d2e4c 100644 --- a/ui/web/src/pages/skills/skill-detail-dialog.tsx +++ b/ui/web/src/pages/skills/skill-detail-dialog.tsx @@ -21,14 +21,27 @@ export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) { {skill.name} {skill.source || "file"} + {skill.visibility && ( + {skill.visibility} + )} + {skill.version ? ( + v{skill.version} + ) : null} {skill.description && (

{skill.description}

)} + {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + {tag} + ))} +
+ )}
{skill.content ? ( -
+
) : ( diff --git a/ui/web/src/pages/skills/skill-edit-dialog.tsx b/ui/web/src/pages/skills/skill-edit-dialog.tsx new file mode 100644 index 00000000..b852da1f --- /dev/null +++ b/ui/web/src/pages/skills/skill-edit-dialog.tsx @@ -0,0 +1,157 @@ +import { useState, useEffect } from "react"; +import { X } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { SkillInfo } from "@/types/skill"; + +interface SkillEditDialogProps { + skill: SkillInfo; + onClose: () => void; + onSave: (id: string, updates: Record) => Promise; +} + +export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps) { + const [name, setName] = useState(skill.name); + const [description, setDescription] = useState(skill.description); + const [visibility, setVisibility] = useState(skill.visibility ?? "private"); + const [tags, setTags] = useState(skill.tags ?? []); + const [tagInput, setTagInput] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + setName(skill.name); + setDescription(skill.description); + setVisibility(skill.visibility ?? "private"); + setTags(skill.tags ?? []); + }, [skill]); + + const addTag = () => { + const t = tagInput.trim().toLowerCase(); + if (t && !tags.includes(t)) { + setTags([...tags, t]); + } + setTagInput(""); + }; + + const removeTag = (tag: string) => { + setTags(tags.filter((t) => t !== tag)); + }; + + const handleSave = async () => { + if (!skill.id) return; + setLoading(true); + setError(""); + try { + await onSave(skill.id, { name, description, visibility, tags }); + onClose(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setLoading(false); + } + }; + + return ( + onClose()}> + + + Edit Skill + + +
+
+ + setName(e.target.value)} + /> +
+ +
+ +