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
This commit is contained in:
viettranx
2026-03-08 00:16:33 +07:00
parent 1bc5393f00
commit c1962e4659
11 changed files with 334 additions and 37 deletions
+21 -2
View File
@@ -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).
+1 -1
View File
@@ -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,
+10 -2
View File
@@ -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 {
+28 -9
View File
@@ -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
}
+10 -7
View File
@@ -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.
+10 -1
View File
@@ -51,6 +51,15 @@ export function useSkills() {
[http, invalidate],
);
const updateSkill = useCallback(
async (id: string, updates: Record<string, unknown>) => {
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 };
}
@@ -21,14 +21,27 @@ export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) {
<DialogTitle className="flex items-center gap-2">
{skill.name}
<Badge variant="outline">{skill.source || "file"}</Badge>
{skill.visibility && (
<Badge variant="secondary">{skill.visibility}</Badge>
)}
{skill.version ? (
<span className="text-xs font-normal text-muted-foreground">v{skill.version}</span>
) : null}
</DialogTitle>
{skill.description && (
<p className="text-sm text-muted-foreground">{skill.description}</p>
)}
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{skill.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
</DialogHeader>
<div className="mt-2">
{skill.content ? (
<div className="rounded-md border bg-muted/30 p-4">
<div className="overflow-hidden rounded-md border bg-muted/30 p-4">
<MarkdownRenderer content={skill.content} />
</div>
) : (
@@ -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<string, unknown>) => Promise<unknown>;
}
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<string[]>(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 (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Skill</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="skill-name">Name</Label>
<Input
id="skill-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="skill-desc">Description</Label>
<Textarea
id="skill-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="space-y-1.5">
<Label>Visibility</Label>
<Select value={visibility} onValueChange={setVisibility}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private (owner only)</SelectItem>
<SelectItem value="internal">Internal (granted agents/users)</SelectItem>
<SelectItem value="public">Public (all agents)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Tags</Label>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addTag(); }
}}
placeholder="Add tag..."
className="flex-1"
/>
<Button type="button" variant="outline" size="sm" onClick={addTag}>
Add
</Button>
</div>
{tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button type="button" onClick={() => removeTag(tag)} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || !name.trim()}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -20,6 +20,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = async () => {
@@ -41,10 +42,25 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
if (!loading) {
setFile(null);
setError("");
setDragging(false);
onOpenChange(v);
}
};
const acceptDrop = (f: File): boolean => f.name.toLowerCase().endsWith(".zip");
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const dropped = e.dataTransfer.files[0];
if (dropped && acceptDrop(dropped)) {
setFile(dropped);
setError("");
} else {
setError("Only .zip files are accepted");
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
@@ -56,14 +72,24 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
</DialogHeader>
<div
className="flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed p-8 text-center transition-colors hover:border-primary/50"
className={`flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed p-8 text-center transition-colors ${
dragging
? "border-primary bg-primary/5"
: "hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<Upload className="h-8 w-8 text-muted-foreground" />
{file ? (
<p className="text-sm font-medium">{file.name} ({(file.size / 1024).toFixed(1)} KB)</p>
) : (
<p className="text-sm text-muted-foreground">Click to select a .zip file</p>
<p className="text-sm text-muted-foreground">
{dragging ? "Drop .zip file here" : "Click or drag & drop a .zip file"}
</p>
)}
<input
ref={inputRef}
+52 -12
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Zap, Eye, RefreshCw, Upload, Trash2 } from "lucide-react";
import { Zap, Eye, Pencil, RefreshCw, Upload, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
@@ -11,18 +11,26 @@ import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useSkills, type SkillInfo } from "./hooks/use-skills";
import { SkillDetailDialog } from "./skill-detail-dialog";
import { SkillUploadDialog } from "./skill-upload-dialog";
import { SkillEditDialog } from "./skill-edit-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
const visibilityColor: Record<string, string> = {
public: "default",
internal: "secondary",
private: "outline",
};
export function SkillsPage() {
const { skills, loading, refresh, getSkill, uploadSkill, deleteSkill } = useSkills();
const { skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill } = useSkills();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
const [search, setSearch] = useState("");
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [editTarget, setEditTarget] = useState<SkillInfo | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SkillInfo | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
@@ -106,6 +114,7 @@ export function SkillsPage() {
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">Description</th>
<th className="px-4 py-3 text-left font-medium">Source</th>
<th className="px-4 py-3 text-left font-medium">Visibility</th>
<th className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
@@ -116,34 +125,54 @@ export function SkillsPage() {
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{skill.name}</span>
{skill.version ? (
<span className="text-xs text-muted-foreground">v{skill.version}</span>
) : null}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="max-w-xs truncate px-4 py-3 text-muted-foreground">
{skill.description || "No description"}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{skill.source || "file"}</Badge>
</td>
<td className="px-4 py-3">
{skill.visibility && (
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
{skill.visibility}
</Badge>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewSkill(skill.name)}
onClick={() => handleViewSkill(skill.slug ?? skill.name)}
disabled={detailLoading}
className="gap-1"
>
<Eye className="h-3.5 w-3.5" /> View
</Button>
{skill.id && (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(skill)}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditTarget(skill)}
className="gap-1"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(skill)}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</td>
@@ -170,6 +199,17 @@ export function SkillsPage() {
/>
)}
{editTarget && (
<SkillEditDialog
skill={editTarget}
onClose={() => setEditTarget(null)}
onSave={async (id, updates) => {
await updateSkill(id, updates);
setEditTarget(null);
}}
/>
)}
<SkillUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
+3
View File
@@ -4,6 +4,9 @@ export interface SkillInfo {
slug?: string;
description: string;
source: string;
visibility?: string;
tags?: string[];
version?: number;
}
export interface SkillWithGrant {