mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
feat(skills): clarify access modes and repair file paths (#31)
* feat(skills): add bulk management actions * feat(skills): improve operations UX * feat(skills): clarify access modes and repair file paths
This commit is contained in:
1 parent
56e227c403
commit
81d96ae014
47 files changed
+1559
-201
No files matched your search
@@ -320,9 +320,9 @@ flowchart TD
|
||||
|
||||
---
|
||||
|
||||
## 12. Skills Grants & Visibility
|
||||
## 12. Skills Grants & Access Mode
|
||||
|
||||
Skill access is controlled through a 3-tier visibility model with explicit agent and user grants.
|
||||
Skill access is controlled through a 3-tier `visibility` field with explicit agent and user grants. The web UI labels this as **Access mode** because `public` means tenant-wide access, not internet publishing.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -335,13 +335,13 @@ flowchart TD
|
||||
GRANT -->|No grant| DENIED["Not accessible"]
|
||||
```
|
||||
|
||||
### Visibility Levels
|
||||
### Access Modes
|
||||
|
||||
| Visibility | Access Rule |
|
||||
|------------|------------|
|
||||
| `public` | All agents and users can discover and use the skill |
|
||||
| `private` | Only the owner (`skills.owner_id = userID`) can access |
|
||||
| `internal` | Requires an explicit agent grant or user grant |
|
||||
| DB value | UI label | Access Rule |
|
||||
|----------|----------|------------|
|
||||
| `private` | Owner only | Only the owner (`skills.owner_id = userID`) can access |
|
||||
| `internal` | Granted agents | Requires an explicit agent grant or user grant |
|
||||
| `public` | All tenant agents | All agents and users in scope can discover and use the skill |
|
||||
|
||||
### Grant Tables
|
||||
|
||||
|
||||
@@ -255,14 +255,16 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
|
||||
|
||||
// Validate visibility enum if present — fail closed before mutating the DB.
|
||||
if v, ok := params.Updates["visibility"]; ok {
|
||||
vs, _ := v.(string)
|
||||
vs, ok := v.(string)
|
||||
if !ok {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, "")))
|
||||
return
|
||||
}
|
||||
if err := skills.ValidateVisibility(vs); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, vs)))
|
||||
return
|
||||
}
|
||||
if vs != "" {
|
||||
params.Updates["visibility"] = skills.NormalizeVisibility(vs)
|
||||
}
|
||||
params.Updates["visibility"] = skills.NormalizeVisibility(vs)
|
||||
}
|
||||
|
||||
if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
@@ -41,14 +43,14 @@ func (s *stubSkillStore) LoadSkill(_ context.Context, name string) (string, bool
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func (s *stubSkillStore) LoadForContext(_ context.Context, _ []string) string { return "" }
|
||||
func (s *stubSkillStore) BuildSummary(_ context.Context, _ []string) string { return "" }
|
||||
func (s *stubSkillStore) LoadForContext(_ context.Context, _ []string) string { return "" }
|
||||
func (s *stubSkillStore) BuildSummary(_ context.Context, _ []string) string { return "" }
|
||||
func (s *stubSkillStore) FilterSkills(_ context.Context, _ []string) []store.SkillInfo {
|
||||
return nil
|
||||
}
|
||||
func (s *stubSkillStore) Version() int64 { return s.version }
|
||||
func (s *stubSkillStore) BumpVersion() { s.version++ }
|
||||
func (s *stubSkillStore) Dirs() []string { return nil }
|
||||
func (s *stubSkillStore) Version() int64 { return s.version }
|
||||
func (s *stubSkillStore) BumpVersion() { s.version++ }
|
||||
func (s *stubSkillStore) Dirs() []string { return nil }
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
@@ -161,3 +163,57 @@ func TestSkillsUpdate_EmptyUpdates_ReturnsInvalidRequest(t *testing.T) {
|
||||
m.handleUpdate(context.Background(), client, req)
|
||||
// No panic = invalid-request (empty updates) path hit
|
||||
}
|
||||
|
||||
func TestSkillsUpdate_NormalizesEmptyVisibilityToPrivate(t *testing.T) {
|
||||
skillID := "0193a5b0-7000-7000-8000-000000000321"
|
||||
store := newUpdatingSkillStore([]store.SkillInfo{{Name: "sk", Slug: "sk", Path: skillID}})
|
||||
m := NewSkillsMethods(store, nil)
|
||||
client := nullClient()
|
||||
req := skillReqFrame(t, protocol.MethodSkillsUpdate, map[string]any{
|
||||
"id": skillID,
|
||||
"updates": map[string]any{"visibility": ""},
|
||||
})
|
||||
|
||||
m.handleUpdate(context.Background(), client, req)
|
||||
|
||||
if got := store.lastUpdates["visibility"]; got != "private" {
|
||||
t.Fatalf("visibility update = %#v, want private", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsUpdate_RejectsNonStringVisibilityBeforeUpdate(t *testing.T) {
|
||||
skillID := "0193a5b0-7000-7000-8000-000000000322"
|
||||
store := newUpdatingSkillStore([]store.SkillInfo{{Name: "sk", Slug: "sk", Path: skillID}})
|
||||
m := NewSkillsMethods(store, nil)
|
||||
client := nullClient()
|
||||
req := skillReqFrame(t, protocol.MethodSkillsUpdate, map[string]any{
|
||||
"id": skillID,
|
||||
"updates": map[string]any{"visibility": true},
|
||||
})
|
||||
|
||||
m.handleUpdate(context.Background(), client, req)
|
||||
|
||||
if store.updateCalled {
|
||||
t.Fatalf("non-string visibility reached UpdateSkill: %+v", store.lastUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
type updatingSkillStore struct {
|
||||
*stubSkillStore
|
||||
lastUpdates map[string]any
|
||||
updateCalled bool
|
||||
}
|
||||
|
||||
func newUpdatingSkillStore(skills []store.SkillInfo) *updatingSkillStore {
|
||||
return &updatingSkillStore{stubSkillStore: newStubSkillStore(skills)}
|
||||
}
|
||||
|
||||
func (s *updatingSkillStore) UpdateSkill(_ context.Context, _ uuid.UUID, updates map[string]any) error {
|
||||
s.updateCalled = true
|
||||
s.lastUpdates = updates
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *updatingSkillStore) GetSkillOwnerID(context.Context, uuid.UUID) (string, bool) {
|
||||
return "", true
|
||||
}
|
||||
@@ -216,6 +216,19 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
delete(updates, "is_system")
|
||||
delete(updates, "enabled")
|
||||
|
||||
if v, ok := updates["visibility"]; ok {
|
||||
vs, ok := v.(string)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, "")})
|
||||
return
|
||||
}
|
||||
if err := skills.ValidateVisibility(vs); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, vs)})
|
||||
return
|
||||
}
|
||||
updates["visibility"] = skills.NormalizeVisibility(vs)
|
||||
}
|
||||
|
||||
if err := h.skills.UpdateSkill(r.Context(), id, updates); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -146,7 +146,7 @@ func (h *SkillsHandler) writeSkillsExportArchive(ctx context.Context, w io.Write
|
||||
|
||||
// SKILL.md — read from filesystem
|
||||
if sk.FilePath != "" {
|
||||
fullPath := config.ExpandHome(sk.FilePath)
|
||||
fullPath := config.ExpandHome(store.SkillMarkdownPath(sk.FilePath))
|
||||
if data, err := os.ReadFile(fullPath); err == nil {
|
||||
if err := addToTar(tw, prefix+"SKILL.md", data); err != nil {
|
||||
slog.Warn("skills.export: write SKILL.md", "slug", sk.Slug, "error", err)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestSkillsHandlerUpdateAcceptsInternalVisibility(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
skillStore := newSkillManageStoreStub(baseDir)
|
||||
id := skillStore.seedCustomSkill("access-mode-skill", filepath.Join(baseDir, "access-mode-skill", "1"), "active", nil)
|
||||
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":"INTERNAL"}`))
|
||||
req.SetPathValue("id", id.String())
|
||||
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.handleUpdate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := skillStore.lastUpdates[id]["visibility"]; got != "internal" {
|
||||
t.Fatalf("updated visibility = %#v, want internal", got)
|
||||
}
|
||||
info, ok := skillStore.GetSkillByID(req.Context(), id)
|
||||
if !ok {
|
||||
t.Fatal("GetSkillByID returned !ok")
|
||||
}
|
||||
if info.Visibility != "internal" {
|
||||
t.Fatalf("stored visibility = %q, want internal", info.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsHandlerUpdateNormalizesEmptyVisibilityToPrivate(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
skillStore := newSkillManageStoreStub(baseDir)
|
||||
id := skillStore.seedCustomSkill("empty-access-mode-skill", filepath.Join(baseDir, "empty-access-mode-skill", "1"), "active", nil)
|
||||
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":""}`))
|
||||
req.SetPathValue("id", id.String())
|
||||
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.handleUpdate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := skillStore.lastUpdates[id]["visibility"]; got != "private" {
|
||||
t.Fatalf("updated visibility = %#v, want private", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsHandlerUpdateRejectsInvalidVisibility(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
skillStore := newSkillManageStoreStub(baseDir)
|
||||
id := skillStore.seedCustomSkill("bad-access-mode-skill", filepath.Join(baseDir, "bad-access-mode-skill", "1"), "active", nil)
|
||||
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":"team"}`))
|
||||
req.SetPathValue("id", id.String())
|
||||
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.handleUpdate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := skillStore.lastUpdates[id]; ok {
|
||||
t.Fatalf("invalid visibility reached UpdateSkill: %+v", skillStore.lastUpdates[id])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsHandlerUpdateRejectsNonStringVisibility(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
skillStore := newSkillManageStoreStub(baseDir)
|
||||
id := skillStore.seedCustomSkill("typed-access-mode-skill", filepath.Join(baseDir, "typed-access-mode-skill", "1"), "active", nil)
|
||||
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":true}`))
|
||||
req.SetPathValue("id", id.String())
|
||||
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.handleUpdate(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := skillStore.lastUpdates[id]; ok {
|
||||
t.Fatalf("non-string visibility reached UpdateSkill: %+v", skillStore.lastUpdates[id])
|
||||
}
|
||||
}
|
||||
@@ -767,6 +767,7 @@ type skillManageStoreStub struct {
|
||||
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
|
||||
grantCalls []skillGrantCall
|
||||
grantErrors map[uuid.UUID]error
|
||||
lastUpdates map[uuid.UUID]map[string]any
|
||||
}
|
||||
|
||||
type skillGrantCall struct {
|
||||
@@ -785,6 +786,7 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
|
||||
systemDirs: map[string]string{},
|
||||
hashBySlug: map[string]string{},
|
||||
grantErrors: map[uuid.UUID]error{},
|
||||
lastUpdates: map[uuid.UUID]map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,6 +917,10 @@ func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, up
|
||||
if status, ok := updates["status"].(string); ok {
|
||||
skill.Status = status
|
||||
}
|
||||
if visibility, ok := updates["visibility"].(string); ok {
|
||||
skill.Visibility = visibility
|
||||
}
|
||||
s.lastUpdates[id] = maps.Clone(updates)
|
||||
s.skills[id] = skill
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -61,16 +61,6 @@ func walkSkillFiles(root string) []fileEntry {
|
||||
return files
|
||||
}
|
||||
|
||||
// skillSlugDir derives the slug parent directory from a DB file_path.
|
||||
// file_path has the form .../slug/version — returns .../slug.
|
||||
// Returns empty string if filePath is empty or malformed.
|
||||
func skillSlugDir(filePath string) string {
|
||||
if filePath == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(filePath)
|
||||
}
|
||||
|
||||
// handleListVersions returns all available version numbers for a skill.
|
||||
func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
@@ -86,7 +76,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
slugDir := skillSlugDir(filePath)
|
||||
slugDir := store.SkillSlugDir(filePath)
|
||||
if slugDir == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"versions": []int{currentVersion},
|
||||
@@ -151,7 +141,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
|
||||
version = parsed
|
||||
}
|
||||
|
||||
slugDir := skillSlugDir(filePath)
|
||||
slugDir := store.SkillSlugDir(filePath)
|
||||
if slugDir == "" {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
||||
return
|
||||
@@ -159,7 +149,20 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
versionDir := filepath.Join(slugDir, strconv.Itoa(version))
|
||||
if _, err := os.Stat(versionDir); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
||||
if !isSystem || h.bundledDir == "" {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
||||
return
|
||||
}
|
||||
bundledDir := filepath.Join(h.bundledDir, slug)
|
||||
if _, err := os.Stat(bundledDir); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
||||
return
|
||||
}
|
||||
files := walkSkillFiles(bundledDir)
|
||||
if files == nil {
|
||||
files = []fileEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,29 +219,33 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
version = parsed
|
||||
}
|
||||
|
||||
slugDir := skillSlugDir(filePath)
|
||||
slugDir := store.SkillSlugDir(filePath)
|
||||
if slugDir == "" {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
|
||||
return
|
||||
}
|
||||
|
||||
versionDir := filepath.Join(slugDir, strconv.Itoa(version))
|
||||
absPath := filepath.Join(versionDir, filepath.Clean(relPath))
|
||||
|
||||
// Verify resolved path is within the version directory
|
||||
if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) {
|
||||
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir)
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
||||
roots := readableSkillRoots(versionDir, slug, isSystem, h.bundledDir)
|
||||
if len(roots) == 0 {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
|
||||
return
|
||||
}
|
||||
|
||||
// Try reading from managed dir; fall back to bundled dir for system skills.
|
||||
data, info, readErr := readSkillFile(absPath)
|
||||
if readErr != nil && isSystem && h.bundledDir != "" {
|
||||
bundledPath := filepath.Join(h.bundledDir, slug, filepath.Clean(relPath))
|
||||
bundledRoot := filepath.Join(h.bundledDir, slug)
|
||||
if strings.HasPrefix(bundledPath, bundledRoot+string(filepath.Separator)) {
|
||||
data, info, readErr = readSkillFile(bundledPath)
|
||||
cleanRelPath := filepath.Clean(relPath)
|
||||
var data []byte
|
||||
var info os.FileInfo
|
||||
var readErr error = os.ErrNotExist
|
||||
for _, root := range roots {
|
||||
absPath := filepath.Join(root, cleanRelPath)
|
||||
if !strings.HasPrefix(absPath, root+string(filepath.Separator)) {
|
||||
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", root)
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
||||
return
|
||||
}
|
||||
data, info, readErr = readSkillFile(absPath)
|
||||
if readErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
@@ -253,6 +260,20 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func readableSkillRoots(versionDir, slug string, isSystem bool, bundledDir string) []string {
|
||||
var roots []string
|
||||
if info, err := os.Stat(versionDir); err == nil && info.IsDir() {
|
||||
roots = append(roots, versionDir)
|
||||
}
|
||||
if isSystem && bundledDir != "" {
|
||||
bundledRoot := filepath.Join(bundledDir, slug)
|
||||
if info, err := os.Stat(bundledRoot); err == nil && info.IsDir() {
|
||||
roots = append(roots, bundledRoot)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// readSkillFile reads a file with security checks (symlink rejection, artifact filtering).
|
||||
// Returns file data, file info, or error.
|
||||
func readSkillFile(absPath string) ([]byte, os.FileInfo, error) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadableSkillRootsFallsBackToBundledSystemSkill(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
missingManaged := filepath.Join(tmp, "managed", "demo", "1")
|
||||
bundled := filepath.Join(tmp, "bundled", "demo")
|
||||
if err := os.MkdirAll(bundled, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roots := readableSkillRoots(missingManaged, "demo", true, filepath.Join(tmp, "bundled"))
|
||||
if len(roots) != 1 || roots[0] != bundled {
|
||||
t.Fatalf("roots = %#v, want bundled fallback", roots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadableSkillRootsDoesNotFallbackForCustomSkill(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
bundled := filepath.Join(tmp, "bundled", "demo")
|
||||
if err := os.MkdirAll(bundled, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roots := readableSkillRoots(filepath.Join(tmp, "missing"), "demo", false, filepath.Join(tmp, "bundled"))
|
||||
if len(roots) != 0 {
|
||||
t.Fatalf("roots = %#v, want no custom fallback", roots)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
|
||||
// Skill visibility values.
|
||||
const (
|
||||
VisibilityPrivate = "private"
|
||||
VisibilityPublic = "public"
|
||||
VisibilityPrivate = "private"
|
||||
VisibilityInternal = "internal"
|
||||
VisibilityPublic = "public"
|
||||
)
|
||||
|
||||
// DefaultVisibility is assigned when a caller does not specify one.
|
||||
@@ -16,10 +17,12 @@ const (
|
||||
const DefaultVisibility = VisibilityPrivate
|
||||
|
||||
// validVisibilities enumerates the accepted enum values. System skills use
|
||||
// "public"; user-published skills default to "private".
|
||||
// "public"; grant-scoped skills use "internal"; user-published skills default
|
||||
// to "private".
|
||||
var validVisibilities = map[string]struct{}{
|
||||
VisibilityPrivate: {},
|
||||
VisibilityPublic: {},
|
||||
VisibilityPrivate: {},
|
||||
VisibilityInternal: {},
|
||||
VisibilityPublic: {},
|
||||
}
|
||||
|
||||
// NormalizeVisibility lowercases + trims the input and returns the default
|
||||
@@ -39,7 +42,7 @@ func ValidateVisibility(v string) error {
|
||||
return nil
|
||||
}
|
||||
if _, ok := validVisibilities[strings.ToLower(strings.TrimSpace(v))]; !ok {
|
||||
return fmt.Errorf("invalid visibility %q: must be one of private, public", v)
|
||||
return fmt.Errorf("invalid visibility %q: must be one of private, internal, public", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ func TestValidateVisibility(t *testing.T) {
|
||||
}{
|
||||
{"empty ok (caller defaults)", "", false},
|
||||
{"private", "private", false},
|
||||
{"internal", "internal", false},
|
||||
{"public", "public", false},
|
||||
{"uppercase normalized", "PRIVATE", false},
|
||||
{"whitespace normalized", " public ", false},
|
||||
{"whitespace normalized", " internal ", false},
|
||||
{"team rejected (v1 scope)", "team", true},
|
||||
{"garbage rejected", "nope", true},
|
||||
}
|
||||
@@ -30,6 +31,7 @@ func TestNormalizeVisibility(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": DefaultVisibility,
|
||||
"private": "private",
|
||||
"INTERNAL": "internal",
|
||||
"PUBLIC": "public",
|
||||
" public ": "public",
|
||||
}
|
||||
|
||||
@@ -619,6 +619,17 @@ func TestBuildSkillInfo_FilePathOverridesBaseDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillInfo_FilePathMayPointToSkillMarkdown(t *testing.T) {
|
||||
fp := "/custom/path/to/skill/SKILL.md"
|
||||
info := buildSkillInfo("id", "n", "s", nil, 1, "/ignored", &fp)
|
||||
if info.BaseDir != "/custom/path/to/skill" {
|
||||
t.Errorf("BaseDir should normalize file path: %q", info.BaseDir)
|
||||
}
|
||||
if info.Path != "/custom/path/to/skill/SKILL.md" {
|
||||
t.Errorf("Path = %q", info.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillInfo_EmptyFilePathFallsBack(t *testing.T) {
|
||||
empty := ""
|
||||
info := buildSkillInfo("id", "n", "slug", nil, 2, "/base", &empty)
|
||||
|
||||
@@ -255,13 +255,13 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st
|
||||
}
|
||||
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
|
||||
if filePath != nil && *filePath != "" {
|
||||
skillDir = *filePath
|
||||
skillDir = store.SkillBaseDir(*filePath)
|
||||
}
|
||||
return store.SkillInfo{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Path: skillDir + "/SKILL.md",
|
||||
Path: store.SkillMarkdownPath(skillDir),
|
||||
BaseDir: skillDir,
|
||||
Source: "managed",
|
||||
Description: d,
|
||||
|
||||
@@ -55,16 +55,15 @@ func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float3
|
||||
}
|
||||
// Use DB file_path when available; fall back to baseDir construction.
|
||||
if row.FilePath != nil && *row.FilePath != "" {
|
||||
r.Path = *row.FilePath + "/SKILL.md"
|
||||
r.Path = store.SkillMarkdownPath(*row.FilePath)
|
||||
} else {
|
||||
r.Path = fmt.Sprintf("%s/%s/%d/SKILL.md", s.baseDir, row.Slug, row.Version)
|
||||
r.Path = store.SkillMarkdownPath(fmt.Sprintf("%s/%s/%d", s.baseDir, row.Slug, row.Version))
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
func buildSkillEmbeddingTenantCond(scope string) string {
|
||||
if scope == "" {
|
||||
return ""
|
||||
|
||||
@@ -91,7 +91,8 @@ func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uui
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE skills SET visibility = 'private', updated_at = NOW()
|
||||
WHERE id = $1 AND visibility = 'internal' AND (is_system = true OR tenant_id = $2)
|
||||
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1)`,
|
||||
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1)
|
||||
AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = $1)`,
|
||||
skillID, tid)
|
||||
if err != nil {
|
||||
slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const SkillMarkdownFilename = "SKILL.md"
|
||||
|
||||
// SkillBaseDir normalizes a persisted skill file_path into the skill directory.
|
||||
// Older import paths may point directly to SKILL.md; managed uploads usually
|
||||
// point to the version directory.
|
||||
func SkillBaseDir(filePath string) string {
|
||||
if filePath == "" {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(filePath)
|
||||
if cleaned == "." {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(filepath.Base(cleaned), SkillMarkdownFilename) {
|
||||
return filepath.Dir(cleaned)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func SkillMarkdownPath(filePath string) string {
|
||||
baseDir := SkillBaseDir(filePath)
|
||||
if baseDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(baseDir, SkillMarkdownFilename)
|
||||
}
|
||||
|
||||
// SkillSlugDir returns the directory that contains version subdirectories.
|
||||
func SkillSlugDir(filePath string) string {
|
||||
baseDir := SkillBaseDir(filePath)
|
||||
if baseDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(baseDir)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSkillBaseDirAcceptsDirectoryPath(t *testing.T) {
|
||||
got := SkillBaseDir("/var/lib/goclaw/data/skills-store/demo/3")
|
||||
if got != "/var/lib/goclaw/data/skills-store/demo/3" {
|
||||
t.Fatalf("SkillBaseDir() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillBaseDirAcceptsSkillMarkdownPath(t *testing.T) {
|
||||
got := SkillBaseDir("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md")
|
||||
if got != "/var/lib/goclaw/data/skills-store/demo/3" {
|
||||
t.Fatalf("SkillBaseDir() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillMarkdownPath(t *testing.T) {
|
||||
got := SkillMarkdownPath("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md")
|
||||
if got != "/var/lib/goclaw/data/skills-store/demo/3/SKILL.md" {
|
||||
t.Fatalf("SkillMarkdownPath() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlugDir(t *testing.T) {
|
||||
got := SkillSlugDir("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md")
|
||||
if got != "/var/lib/goclaw/data/skills-store/demo" {
|
||||
t.Fatalf("SkillSlugDir() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -218,13 +218,13 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st
|
||||
}
|
||||
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
|
||||
if filePath != nil && *filePath != "" {
|
||||
skillDir = *filePath
|
||||
skillDir = store.SkillBaseDir(*filePath)
|
||||
}
|
||||
return store.SkillInfo{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Path: skillDir + "/SKILL.md",
|
||||
Path: store.SkillMarkdownPath(skillDir),
|
||||
BaseDir: skillDir,
|
||||
Source: "managed",
|
||||
Description: d,
|
||||
|
||||
@@ -94,8 +94,9 @@ func (s *SQLiteSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE skills SET visibility = 'private', updated_at = ?
|
||||
WHERE id = ? AND visibility = 'internal' AND (is_system = 1 OR tenant_id = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)`,
|
||||
time.Now().UTC(), skillID, tid, skillID)
|
||||
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = ?)`,
|
||||
time.Now().UTC(), skillID, tid, skillID, skillID)
|
||||
if err != nil {
|
||||
slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err)
|
||||
}
|
||||
|
||||
@@ -216,6 +216,40 @@ func TestSQLiteSkillStore_RevokeFromAgentDoesNotDemoteCrossTenantSkill(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteSkillStore_RevokeFromAgentKeepsInternalWhenUserGrantRemains(t *testing.T) {
|
||||
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
|
||||
tenantID, agentID := seedSQLiteTenantAgent(t, db)
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
|
||||
skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Shared Internal Skill",
|
||||
Slug: "shared-internal-skill-" + tenantID.String()[:8],
|
||||
OwnerID: "owner-user",
|
||||
Visibility: "internal",
|
||||
FilePath: filepath.Join(t.TempDir(), "shared-internal-skill", "1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSkillManaged error: %v", err)
|
||||
}
|
||||
if err := skillStore.GrantToAgent(ctx, skillID, agentID, 1, "owner-user", true); err != nil {
|
||||
t.Fatalf("GrantToAgent error: %v", err)
|
||||
}
|
||||
if err := skillStore.GrantToUser(ctx, skillID, "granted-user", "owner-user"); err != nil {
|
||||
t.Fatalf("GrantToUser error: %v", err)
|
||||
}
|
||||
if err := skillStore.RevokeFromAgent(ctx, skillID, agentID); err != nil {
|
||||
t.Fatalf("RevokeFromAgent error: %v", err)
|
||||
}
|
||||
|
||||
got, ok := skillStore.GetSkillByID(ctx, skillID)
|
||||
if !ok {
|
||||
t.Fatal("GetSkillByID returned !ok")
|
||||
}
|
||||
if got.Visibility != "internal" {
|
||||
t.Fatalf("visibility after revoking last agent grant = %q, want internal because a user grant remains", got.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testing.T) {
|
||||
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
|
||||
tenantA, _ := seedSQLiteTenantAgent(t, db)
|
||||
@@ -253,6 +287,97 @@ func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testin
|
||||
t.Fatalf("system skill %s not returned for tenant A", skillID)
|
||||
}
|
||||
|
||||
func TestSQLiteSkillStore_ListAccessibleHonorsAccessModes(t *testing.T) {
|
||||
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
|
||||
tenantID, agentA := seedSQLiteTenantAgent(t, db)
|
||||
agentB := uuid.New()
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
||||
VALUES (?, ?, ?, 'predefined', 'active', 'test', 'test-model', 'user-2')`,
|
||||
agentB.String(), tenantID.String(), "agent-"+agentB.String()[:8],
|
||||
); err != nil {
|
||||
t.Fatalf("insert second agent: %v", err)
|
||||
}
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
|
||||
privateID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Owner Only Skill",
|
||||
Slug: "owner-only-skill-" + tenantID.String()[:8],
|
||||
OwnerID: "owner-user",
|
||||
Visibility: "private",
|
||||
FilePath: filepath.Join(t.TempDir(), "owner-only", "1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSkillManaged private error: %v", err)
|
||||
}
|
||||
internalID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Granted Agents Skill",
|
||||
Slug: "granted-agents-skill-" + tenantID.String()[:8],
|
||||
OwnerID: "owner-user",
|
||||
Visibility: "internal",
|
||||
FilePath: filepath.Join(t.TempDir(), "granted-agents", "1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSkillManaged internal error: %v", err)
|
||||
}
|
||||
publicID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Tenant Wide Skill",
|
||||
Slug: "tenant-wide-skill-" + tenantID.String()[:8],
|
||||
OwnerID: "owner-user",
|
||||
Visibility: "public",
|
||||
FilePath: filepath.Join(t.TempDir(), "tenant-wide", "1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSkillManaged public error: %v", err)
|
||||
}
|
||||
if err := skillStore.GrantToAgent(ctx, internalID, agentA, 1, "owner-user", true); err != nil {
|
||||
t.Fatalf("GrantToAgent internal error: %v", err)
|
||||
}
|
||||
|
||||
viewerA := listAccessibleSlugs(t, skillStore, ctx, agentA, "viewer-user")
|
||||
if !viewerA["granted-agents-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("granted agent did not receive internal skill; got %v", viewerA)
|
||||
}
|
||||
if !viewerA["tenant-wide-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("agent did not receive public skill; got %v", viewerA)
|
||||
}
|
||||
if viewerA["owner-only-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("non-owner received private skill; got %v", viewerA)
|
||||
}
|
||||
|
||||
viewerB := listAccessibleSlugs(t, skillStore, ctx, agentB, "viewer-user")
|
||||
if viewerB["granted-agents-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("ungranted agent received internal skill; got %v", viewerB)
|
||||
}
|
||||
if !viewerB["tenant-wide-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("ungranted agent did not receive public skill; got %v", viewerB)
|
||||
}
|
||||
|
||||
owner := listAccessibleSlugs(t, skillStore, ctx, agentB, "owner-user")
|
||||
if !owner["owner-only-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("owner did not receive private skill %s; got %v", privateID, owner)
|
||||
}
|
||||
if !owner["tenant-wide-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("owner did not receive public skill %s; got %v", publicID, owner)
|
||||
}
|
||||
if owner["granted-agents-skill-"+tenantID.String()[:8]] {
|
||||
t.Fatalf("owner received internal skill without grant; got %v", owner)
|
||||
}
|
||||
}
|
||||
|
||||
func listAccessibleSlugs(t *testing.T, skillStore *SQLiteSkillStore, ctx context.Context, agentID uuid.UUID, userID string) map[string]bool {
|
||||
t.Helper()
|
||||
skills, err := skillStore.ListAccessible(ctx, agentID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccessible error: %v", err)
|
||||
}
|
||||
slugs := make(map[string]bool, len(skills))
|
||||
for _, skill := range skills {
|
||||
slugs[skill.Slug] = true
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func newTestSQLiteSkillStore(t *testing.T) (context.Context, *SQLiteSkillStore) {
|
||||
ctx, skillStore, _ := newTestSQLiteSkillStoreWithDB(t)
|
||||
return ctx, skillStore
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
// - "private" skills are only visible to the owner. Three identity strings
|
||||
// are considered (actor, user, sender) to match the same identities
|
||||
// isOwnerOfSkill checks for backward compatibility (#915).
|
||||
// - "internal" skills require explicit grants, which are evaluated in
|
||||
// ListAccessible; this baseline helper hides them.
|
||||
//
|
||||
// Admin/master-scope bypass is the caller's responsibility — this helper
|
||||
// reflects the non-privileged baseline.
|
||||
@@ -34,6 +36,8 @@ func IsSkillVisibleTo(ctx context.Context, ownerID, visibility string, isSystem
|
||||
userID := UserIDFromContext(ctx)
|
||||
senderID := SenderIDFromContext(ctx)
|
||||
return ownerID == actorID || ownerID == userID || ownerID == senderID
|
||||
case "internal":
|
||||
return false
|
||||
default:
|
||||
// Unknown enum value: fail closed (hide).
|
||||
return false
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestIsSkillVisibleTo(t *testing.T) {
|
||||
{"private visible to owner", alice, "private", false, true},
|
||||
{"private hidden from non-owner", bob, "private", false, false},
|
||||
{"private with no owner treated as public", "", "private", false, true},
|
||||
{"internal requires explicit grant outside this helper", bob, "internal", false, false},
|
||||
{"unknown enum fails closed", bob, "team", false, false},
|
||||
{"uppercase private matched for owner", alice, "PRIVATE", false, true},
|
||||
{"whitespace public treated as public", bob, " public ", false, true},
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full flex-col border-r bg-sidebar text-sidebar-foreground transition-all duration-200",
|
||||
"flex h-full flex-col overflow-hidden overscroll-none border-r bg-sidebar text-sidebar-foreground transition-all duration-200",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@@ -83,7 +83,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto px-2 py-4">
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto overscroll-contain px-2 py-4">
|
||||
<SidebarGroup label={t("groups.core")} collapsed={collapsed}>
|
||||
<SidebarItem to={ROUTES.OVERVIEW} icon={LayoutDashboard} label={t("nav.overview")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.CHAT} icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
|
||||
@@ -151,7 +151,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
</nav>
|
||||
|
||||
{/* Footer: connection status */}
|
||||
<div className={cn("border-t py-3", collapsed ? "px-2 flex justify-center" : "px-4")}>
|
||||
<div className={cn("shrink-0 overscroll-none border-t py-3", collapsed ? "px-2 flex justify-center" : "px-4")}>
|
||||
<ConnectionStatus collapsed={collapsed} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -13,11 +13,40 @@
|
||||
"author": "Author",
|
||||
"agents": "Agents",
|
||||
"owner": "Owner",
|
||||
"visibility": "Visibility",
|
||||
"visibility": "Access mode",
|
||||
"accessMode": "Access mode",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"noDescription": "No description",
|
||||
"status": {
|
||||
"unmanaged": "Unmanaged",
|
||||
"missingDepsCount": "{{count}} missing"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filter",
|
||||
"all": "All skills",
|
||||
"attention": "Needs attention",
|
||||
"missing-deps": "Missing deps",
|
||||
"disabled": "Disabled",
|
||||
"archived": "Archived",
|
||||
"unmanaged": "Unmanaged",
|
||||
"clear": "Clear filters"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sort",
|
||||
"name": "Name",
|
||||
"deps": "Missing deps",
|
||||
"version": "Version"
|
||||
},
|
||||
"health": {
|
||||
"total": "Total",
|
||||
"attention": "Needs attention",
|
||||
"missingDeps": "Missing deps",
|
||||
"disabled": "Disabled",
|
||||
"archived": "Archived",
|
||||
"unmanaged": "Unmanaged"
|
||||
},
|
||||
"owner": "Owner",
|
||||
"unknownOwner": "Unknown",
|
||||
"grants": {
|
||||
@@ -54,10 +83,21 @@
|
||||
"deleted": "Deleted {{count}} skill(s)",
|
||||
"failed": "Bulk action failed",
|
||||
"deleteTitle": "Delete selected skills",
|
||||
"deleteDescription": "This will delete {{count}} selected custom skill(s). Type DELETE to confirm."
|
||||
"deleteDescription": "This will delete {{count}} selected custom skill(s). Type DELETE to confirm.",
|
||||
"customAvailable": "{{count}} custom action(s) available",
|
||||
"skippedSystem": "{{count}} system skill(s) skipped",
|
||||
"customOnlyReason": "This action only applies to custom skills",
|
||||
"noAgentsReason": "No current agents available"
|
||||
},
|
||||
"visibility": {
|
||||
"clickToCycle": "Click to change visibility"
|
||||
"clickToCycle": "Click to change access mode"
|
||||
},
|
||||
"accessMode": {
|
||||
"private": "Owner only",
|
||||
"internal": "Granted agents",
|
||||
"public": "All tenant agents",
|
||||
"unknown": "Unknown ({{value}})",
|
||||
"clickToCycle": "Click to change access mode"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Skill",
|
||||
@@ -96,14 +136,21 @@
|
||||
"agentManagers": "Grant manager access to agents",
|
||||
"managerAgentsHelp": "Selected current agents can update this skill later.",
|
||||
"selectAllAgents": "Select all agents",
|
||||
"selectAllCurrentAgents": "Select all current agents",
|
||||
"clearAgents": "Clear agents",
|
||||
"noAgents": "No agents available"
|
||||
"noAgents": "No agents available",
|
||||
"selectedAgentsCount": "{{selected}} of {{total}} current agents selected",
|
||||
"summaryUploaded": "{{count}} uploaded",
|
||||
"summaryWarnings": "{{count}} warning(s)",
|
||||
"summaryUnchanged": "{{count}} unchanged",
|
||||
"summaryFailed": "{{count}} failed"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Skill",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"visibility": "Visibility",
|
||||
"visibility": "Access mode",
|
||||
"accessMode": "Access mode",
|
||||
"tags": "Tags",
|
||||
"addTag": "Add tag...",
|
||||
"add": "Add",
|
||||
@@ -149,7 +196,7 @@
|
||||
"pythonLabel": "Python",
|
||||
"nodeLabel": "Node.js",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Missing deps",
|
||||
"statusArchived": "Archived",
|
||||
"runtimeMissing": "Runtime Prerequisites Missing",
|
||||
"runtimeMissingDesc": "Core skills require Python and/or Node.js runtimes inside the GoClaw runtime container. Host-installed runtimes are not used here. If you are using the published latest image, missing runtimes can be expected there.",
|
||||
"runtimeMissingAction": "Open Packages to verify or install runtimes in this container.",
|
||||
@@ -165,7 +212,12 @@
|
||||
"files": "Files",
|
||||
"version": "Version:",
|
||||
"current": "(current)",
|
||||
"noContent": "No content available."
|
||||
"noContent": "No content available.",
|
||||
"fileSearch": "Search files...",
|
||||
"filesShown": "{{shown}} of {{total}} files",
|
||||
"copyLink": "Copy link",
|
||||
"copySuccess": "Deeplink copied",
|
||||
"copyFailed": "Failed to copy deeplink"
|
||||
},
|
||||
"toast": {
|
||||
"updated": "Skill updated",
|
||||
|
||||
@@ -12,14 +12,50 @@
|
||||
"source": "Nguồn",
|
||||
"author": "Tác giả",
|
||||
"agents": "Agents",
|
||||
"visibility": "Hiển thị",
|
||||
"visibility": "Chế độ truy cập",
|
||||
"accessMode": "Chế độ truy cập",
|
||||
"status": "Trạng thái",
|
||||
"actions": "Thao tác",
|
||||
"owner": "Chủ sở hữu"
|
||||
},
|
||||
"noDescription": "Không có mô tả",
|
||||
"status": {
|
||||
"unmanaged": "Chưa có agent quản lý",
|
||||
"missingDepsCount": "Thiếu {{count}}"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Bộ lọc",
|
||||
"all": "Tất cả skill",
|
||||
"attention": "Cần xử lý",
|
||||
"missing-deps": "Thiếu deps",
|
||||
"disabled": "Đã tắt",
|
||||
"archived": "Đã lưu trữ",
|
||||
"unmanaged": "Chưa có quản lý",
|
||||
"clear": "Xóa bộ lọc"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Sắp xếp",
|
||||
"name": "Tên",
|
||||
"deps": "Thiếu deps",
|
||||
"version": "Phiên bản"
|
||||
},
|
||||
"health": {
|
||||
"total": "Tổng",
|
||||
"attention": "Cần xử lý",
|
||||
"missingDeps": "Thiếu deps",
|
||||
"disabled": "Đã tắt",
|
||||
"archived": "Đã lưu trữ",
|
||||
"unmanaged": "Chưa có quản lý"
|
||||
},
|
||||
"visibility": {
|
||||
"clickToCycle": "Nhấp để thay đổi hiển thị"
|
||||
"clickToCycle": "Nhấp để đổi chế độ truy cập"
|
||||
},
|
||||
"accessMode": {
|
||||
"private": "Chỉ chủ sở hữu",
|
||||
"internal": "Agent được cấp quyền",
|
||||
"public": "Tất cả agent trong tenant",
|
||||
"unknown": "Không rõ ({{value}})",
|
||||
"clickToCycle": "Nhấp để đổi chế độ truy cập"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Xóa skill",
|
||||
@@ -58,14 +94,21 @@
|
||||
"agentManagers": "Cấp quyền quản lý cho agents",
|
||||
"managerAgentsHelp": "Các agent hiện tại được chọn có thể cập nhật skill này sau.",
|
||||
"selectAllAgents": "Chọn tất cả agents",
|
||||
"selectAllCurrentAgents": "Chọn tất cả agent hiện tại",
|
||||
"clearAgents": "Bỏ chọn agents",
|
||||
"noAgents": "Chưa có agent"
|
||||
"noAgents": "Chưa có agent",
|
||||
"selectedAgentsCount": "Đã chọn {{selected}} / {{total}} agent hiện tại",
|
||||
"summaryUploaded": "{{count}} đã tải lên",
|
||||
"summaryWarnings": "{{count}} cảnh báo",
|
||||
"summaryUnchanged": "{{count}} không đổi",
|
||||
"summaryFailed": "{{count}} thất bại"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Chỉnh sửa skill",
|
||||
"name": "Tên",
|
||||
"description": "Mô tả",
|
||||
"visibility": "Hiển thị",
|
||||
"visibility": "Chế độ truy cập",
|
||||
"accessMode": "Chế độ truy cập",
|
||||
"tags": "Thẻ",
|
||||
"addTag": "Thêm thẻ...",
|
||||
"add": "Thêm",
|
||||
@@ -111,7 +154,7 @@
|
||||
"pythonLabel": "Python",
|
||||
"nodeLabel": "Node.js",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Missing deps",
|
||||
"statusArchived": "Đã lưu trữ",
|
||||
"runtimeMissing": "Runtime Prerequisites Missing",
|
||||
"runtimeMissingDesc": "Các core skill cần runtime Python và/hoặc Node.js bên trong container runtime GoClaw. Runtime cài trên máy host sẽ không được dùng ở đây. Nếu bạn đang dùng image latest đã phát hành thì việc thiếu runtime có thể là bình thường.",
|
||||
"runtimeMissingAction": "Mở trang Packages để kiểm tra hoặc cài runtime trong container này.",
|
||||
@@ -127,7 +170,12 @@
|
||||
"files": "Tệp",
|
||||
"version": "Phiên bản:",
|
||||
"current": "(hiện tại)",
|
||||
"noContent": "Không có nội dung."
|
||||
"noContent": "Không có nội dung.",
|
||||
"fileSearch": "Tìm trong tệp...",
|
||||
"filesShown": "{{shown}} / {{total}} tệp",
|
||||
"copyLink": "Copy link",
|
||||
"copySuccess": "Đã copy deeplink",
|
||||
"copyFailed": "Không thể copy deeplink"
|
||||
},
|
||||
"toast": {
|
||||
"updated": "Đã cập nhật skill",
|
||||
@@ -174,6 +222,10 @@
|
||||
"deleted": "Đã xóa {{count}} skill",
|
||||
"failed": "Bulk action thất bại",
|
||||
"deleteTitle": "Xóa các skill đã chọn",
|
||||
"deleteDescription": "Thao tác này sẽ xóa {{count}} custom skill đã chọn. Gõ DELETE để xác nhận."
|
||||
"deleteDescription": "Thao tác này sẽ xóa {{count}} custom skill đã chọn. Gõ DELETE để xác nhận.",
|
||||
"customAvailable": "{{count}} thao tác custom khả dụng",
|
||||
"skippedSystem": "Bỏ qua {{count}} skill hệ thống",
|
||||
"customOnlyReason": "Thao tác này chỉ áp dụng cho custom skill",
|
||||
"noAgentsReason": "Chưa có agent hiện tại"
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,50 @@
|
||||
"source": "来源",
|
||||
"author": "作者",
|
||||
"agents": "Agent",
|
||||
"visibility": "可见性",
|
||||
"visibility": "访问模式",
|
||||
"accessMode": "访问模式",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
"owner": "所有者"
|
||||
},
|
||||
"noDescription": "暂无描述",
|
||||
"status": {
|
||||
"unmanaged": "未管理",
|
||||
"missingDepsCount": "缺少 {{count}} 项"
|
||||
},
|
||||
"filters": {
|
||||
"label": "筛选",
|
||||
"all": "全部 Skill",
|
||||
"attention": "需要处理",
|
||||
"missing-deps": "缺少依赖",
|
||||
"disabled": "已禁用",
|
||||
"archived": "已归档",
|
||||
"unmanaged": "未管理",
|
||||
"clear": "清除筛选"
|
||||
},
|
||||
"sort": {
|
||||
"label": "排序",
|
||||
"name": "名称",
|
||||
"deps": "缺少依赖",
|
||||
"version": "版本"
|
||||
},
|
||||
"health": {
|
||||
"total": "总数",
|
||||
"attention": "需要处理",
|
||||
"missingDeps": "缺少依赖",
|
||||
"disabled": "已禁用",
|
||||
"archived": "已归档",
|
||||
"unmanaged": "未管理"
|
||||
},
|
||||
"visibility": {
|
||||
"clickToCycle": "点击更改可见性"
|
||||
"clickToCycle": "点击更改访问模式"
|
||||
},
|
||||
"accessMode": {
|
||||
"private": "仅所有者",
|
||||
"internal": "已授权 Agent",
|
||||
"public": "租户内所有 Agent",
|
||||
"unknown": "未知({{value}})",
|
||||
"clickToCycle": "点击更改访问模式"
|
||||
},
|
||||
"delete": {
|
||||
"title": "删除Skill",
|
||||
@@ -58,14 +94,21 @@
|
||||
"agentManagers": "授予 Agent 管理权限",
|
||||
"managerAgentsHelp": "所选当前 Agent 以后可以更新此 Skill。",
|
||||
"selectAllAgents": "选择所有 Agent",
|
||||
"selectAllCurrentAgents": "选择所有当前 Agent",
|
||||
"clearAgents": "清除 Agent",
|
||||
"noAgents": "暂无 Agent"
|
||||
"noAgents": "暂无 Agent",
|
||||
"selectedAgentsCount": "已选择 {{selected}} / {{total}} 个当前 Agent",
|
||||
"summaryUploaded": "已上传 {{count}} 个",
|
||||
"summaryWarnings": "{{count}} 个警告",
|
||||
"summaryUnchanged": "{{count}} 个未更改",
|
||||
"summaryFailed": "{{count}} 个失败"
|
||||
},
|
||||
"edit": {
|
||||
"title": "编辑Skill",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"visibility": "可见性",
|
||||
"visibility": "访问模式",
|
||||
"accessMode": "访问模式",
|
||||
"tags": "标签",
|
||||
"addTag": "添加标签...",
|
||||
"add": "添加",
|
||||
@@ -111,7 +154,7 @@
|
||||
"pythonLabel": "Python",
|
||||
"nodeLabel": "Node.js",
|
||||
"statusActive": "Active",
|
||||
"statusArchived": "Missing deps",
|
||||
"statusArchived": "已归档",
|
||||
"runtimeMissing": "Runtime Prerequisites Missing",
|
||||
"runtimeMissingDesc": "核心技能需要在 GoClaw 运行时容器内提供 Python 和/或 Node.js 运行时。宿主机上的运行时不会在这里被使用。如果你使用的是已发布的 latest 镜像,那么缺少这些运行时可能是正常现象。",
|
||||
"runtimeMissingAction": "打开 Packages 页面,在此容器中检查或安装运行时。",
|
||||
@@ -127,7 +170,12 @@
|
||||
"files": "文件",
|
||||
"version": "版本:",
|
||||
"current": "(当前)",
|
||||
"noContent": "无可用内容。"
|
||||
"noContent": "无可用内容。",
|
||||
"fileSearch": "搜索文件...",
|
||||
"filesShown": "{{shown}} / {{total}} 个文件",
|
||||
"copyLink": "复制链接",
|
||||
"copySuccess": "已复制 Deeplink",
|
||||
"copyFailed": "复制 Deeplink 失败"
|
||||
},
|
||||
"toast": {
|
||||
"updated": "技能已更新",
|
||||
@@ -174,6 +222,10 @@
|
||||
"deleted": "已删除 {{count}} 个 Skill",
|
||||
"failed": "批量操作失败",
|
||||
"deleteTitle": "删除所选 Skill",
|
||||
"deleteDescription": "这将删除 {{count}} 个所选自定义 Skill。请输入 DELETE 确认。"
|
||||
"deleteDescription": "这将删除 {{count}} 个所选自定义 Skill。请输入 DELETE 确认。",
|
||||
"customAvailable": "{{count}} 个自定义操作可用",
|
||||
"skippedSystem": "已跳过 {{count}} 个系统 Skill",
|
||||
"customOnlyReason": "此操作仅适用于自定义 Skill",
|
||||
"noAgentsReason": "暂无当前 Agent"
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,13 @@
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanVersion } from "./clean-version";
|
||||
|
||||
describe("cleanVersion", () => {
|
||||
it("keeps beta version identity and drops build slug", () => {
|
||||
expect(cleanVersion("v3.12.0-beta.13-skills-ux-99f30ecc")).toBe("v3.12.0.beta.13");
|
||||
});
|
||||
|
||||
it("keeps rc version identity", () => {
|
||||
expect(cleanVersion("v3.12.0-rc.2-linux-amd64")).toBe("v3.12.0.rc.2");
|
||||
});
|
||||
|
||||
it("strips git distance metadata from stable versions", () => {
|
||||
expect(cleanVersion("v2.5.1-3-g4fd653c1")).toBe("v2.5.1");
|
||||
});
|
||||
|
||||
it("leaves non-semver versions unchanged", () => {
|
||||
expect(cleanVersion("dev")).toBe("dev");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
// Strip git build metadata from version string.
|
||||
// "v2.5.1-3-g4fd653c1" → "v2.5.1", "dev" → "dev"
|
||||
// Keep release/beta identity visible while hiding build slugs and git metadata.
|
||||
// "v3.12.0-beta.13-skills-ux-99f30ecc" -> "v3.12.0.beta.13"
|
||||
// "v2.5.1-3-g4fd653c1" -> "v2.5.1", "dev" -> "dev"
|
||||
export function cleanVersion(v: string): string {
|
||||
const match = v.match(/^(v?\d+\.\d+\.\d+)/);
|
||||
return match?.[1] ?? v;
|
||||
const match = v.match(/^(v?\d+\.\d+\.\d+)(?:-((?:beta|alpha|rc)\.?\d*))?/i);
|
||||
if (!match) return v;
|
||||
|
||||
const base = match[1]!;
|
||||
const prerelease = match[2];
|
||||
if (!prerelease) return base;
|
||||
const normalizedPrerelease = prerelease.replace(/^(beta|alpha|rc)(\d+)$/i, "$1.$2");
|
||||
return `${base}.${normalizedPrerelease}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNextSkillAccessMode,
|
||||
getSkillAccessModeBadgeVariant,
|
||||
getSkillAccessModeKey,
|
||||
} from "./skill-access-mode";
|
||||
|
||||
describe("skill access mode helpers", () => {
|
||||
it("maps raw visibility values to stable access-mode keys", () => {
|
||||
expect(getSkillAccessModeKey("private")).toBe("private");
|
||||
expect(getSkillAccessModeKey("internal")).toBe("internal");
|
||||
expect(getSkillAccessModeKey("public")).toBe("public");
|
||||
expect(getSkillAccessModeKey("team")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("cycles known modes and normalizes unknown values to private", () => {
|
||||
expect(getNextSkillAccessMode("private")).toBe("internal");
|
||||
expect(getNextSkillAccessMode("internal")).toBe("public");
|
||||
expect(getNextSkillAccessMode("public")).toBe("private");
|
||||
expect(getNextSkillAccessMode("team")).toBe("private");
|
||||
});
|
||||
|
||||
it("uses conservative badge variants for unknown values", () => {
|
||||
expect(getSkillAccessModeBadgeVariant("public")).toBe("default");
|
||||
expect(getSkillAccessModeBadgeVariant("internal")).toBe("secondary");
|
||||
expect(getSkillAccessModeBadgeVariant("private")).toBe("outline");
|
||||
expect(getSkillAccessModeBadgeVariant("team")).toBe("outline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export type SkillAccessMode = "private" | "internal" | "public";
|
||||
|
||||
export const SKILL_ACCESS_MODE_ORDER: SkillAccessMode[] = ["private", "internal", "public"];
|
||||
|
||||
export type SkillAccessModeKey = SkillAccessMode | "unknown";
|
||||
|
||||
export function getSkillAccessModeKey(visibility?: string): SkillAccessModeKey {
|
||||
return isSkillAccessMode(visibility) ? visibility : "unknown";
|
||||
}
|
||||
|
||||
export function getNextSkillAccessMode(visibility?: string): SkillAccessMode {
|
||||
if (!isSkillAccessMode(visibility)) return "private";
|
||||
const idx = SKILL_ACCESS_MODE_ORDER.indexOf(visibility);
|
||||
return SKILL_ACCESS_MODE_ORDER[(idx + 1) % SKILL_ACCESS_MODE_ORDER.length] ?? "private";
|
||||
}
|
||||
|
||||
export function getSkillAccessModeBadgeVariant(visibility?: string): "default" | "secondary" | "outline" {
|
||||
switch (getSkillAccessModeKey(visibility)) {
|
||||
case "public":
|
||||
return "default";
|
||||
case "internal":
|
||||
return "secondary";
|
||||
case "private":
|
||||
case "unknown":
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function isSkillAccessMode(value?: string): value is SkillAccessMode {
|
||||
return value === "private" || value === "internal" || value === "public";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeSkillStatuses } from "./skill-upload-summary";
|
||||
|
||||
describe("skill upload summary", () => {
|
||||
it("separates upload result states", () => {
|
||||
expect(summarizeSkillStatuses([
|
||||
{ status: "valid" },
|
||||
{ status: "success" },
|
||||
{ status: "warning" },
|
||||
{ status: "unchanged" },
|
||||
{ status: "invalid" },
|
||||
{ status: "error" },
|
||||
{ status: "uploading" },
|
||||
])).toEqual({
|
||||
total: 7,
|
||||
valid: 1,
|
||||
uploaded: 1,
|
||||
warnings: 1,
|
||||
unchanged: 1,
|
||||
failed: 1,
|
||||
invalid: 1,
|
||||
inProgress: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FileEntry, SkillEntry, SkillStatus } from "./skill-upload-types";
|
||||
|
||||
export interface SkillUploadSummary {
|
||||
total: number;
|
||||
valid: number;
|
||||
uploaded: number;
|
||||
warnings: number;
|
||||
unchanged: number;
|
||||
failed: number;
|
||||
invalid: number;
|
||||
inProgress: number;
|
||||
}
|
||||
|
||||
const EMPTY_SUMMARY: SkillUploadSummary = {
|
||||
total: 0,
|
||||
valid: 0,
|
||||
uploaded: 0,
|
||||
warnings: 0,
|
||||
unchanged: 0,
|
||||
failed: 0,
|
||||
invalid: 0,
|
||||
inProgress: 0,
|
||||
};
|
||||
|
||||
export function summarizeUploadEntries(entries: FileEntry[]): SkillUploadSummary {
|
||||
return summarizeSkillStatuses(entries.flatMap((entry) => entry.skills));
|
||||
}
|
||||
|
||||
export function summarizeSkillStatuses(skills: Pick<SkillEntry, "status">[]): SkillUploadSummary {
|
||||
return skills.reduce<SkillUploadSummary>((summary, skill) => {
|
||||
summary.total += 1;
|
||||
applyStatus(summary, skill.status);
|
||||
return summary;
|
||||
}, { ...EMPTY_SUMMARY });
|
||||
}
|
||||
|
||||
function applyStatus(summary: SkillUploadSummary, status: SkillStatus) {
|
||||
switch (status) {
|
||||
case "valid":
|
||||
summary.valid += 1;
|
||||
break;
|
||||
case "success":
|
||||
summary.uploaded += 1;
|
||||
break;
|
||||
case "warning":
|
||||
summary.warnings += 1;
|
||||
break;
|
||||
case "unchanged":
|
||||
summary.unchanged += 1;
|
||||
break;
|
||||
case "invalid":
|
||||
summary.invalid += 1;
|
||||
break;
|
||||
case "error":
|
||||
summary.failed += 1;
|
||||
break;
|
||||
case "validating":
|
||||
case "uploading":
|
||||
summary.inProgress += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SkillInfo } from "../hooks/use-skills";
|
||||
import { deriveSkillStats, displayDependencyName, filterSkills, sortSkills } from "./skills-filtering";
|
||||
|
||||
function skill(overrides: Partial<SkillInfo>): SkillInfo {
|
||||
return {
|
||||
id: overrides.name ?? "id",
|
||||
name: overrides.name ?? "Skill",
|
||||
description: overrides.description ?? "",
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("skills filtering", () => {
|
||||
const skills = [
|
||||
skill({ name: "Core", is_system: true, manager_agents: [] }),
|
||||
skill({ name: "Missing", missing_deps: ["pip:google", "npm:zod"], manager_agents: [{ id: "a1" }] }),
|
||||
skill({ name: "Disabled", enabled: false, manager_agents: [{ id: "a2" }] }),
|
||||
skill({ name: "Archived", status: "archived", manager_agents: [{ id: "a3" }] }),
|
||||
skill({ name: "Unmanaged", manager_agents: [] }),
|
||||
];
|
||||
|
||||
it("derives attention stats without counting system skills as unmanaged", () => {
|
||||
expect(deriveSkillStats(skills)).toEqual({
|
||||
total: 5,
|
||||
missingDeps: 1,
|
||||
disabled: 1,
|
||||
archived: 1,
|
||||
unmanaged: 1,
|
||||
attention: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters attention and unmanaged skills", () => {
|
||||
expect(filterSkills(skills, { q: "", filter: "attention", agent: null }).map((s) => s.name))
|
||||
.toEqual(["Missing", "Disabled", "Archived", "Unmanaged"]);
|
||||
expect(filterSkills(skills, { q: "", filter: "unmanaged", agent: null }).map((s) => s.name))
|
||||
.toEqual(["Unmanaged"]);
|
||||
});
|
||||
|
||||
it("searches skill and agent metadata", () => {
|
||||
expect(filterSkills(skills, { q: "a2", filter: "all", agent: null }).map((s) => s.name)).toEqual(["Disabled"]);
|
||||
});
|
||||
|
||||
it("sorts dependency-heavy skills first", () => {
|
||||
expect(sortSkills(skills, "deps")[0]?.name).toBe("Missing");
|
||||
});
|
||||
|
||||
it("strips dependency runtime prefixes only for display", () => {
|
||||
expect(displayDependencyName("pip:google")).toBe("google");
|
||||
expect(displayDependencyName("core")).toBe("core");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { SkillInfo } from "../hooks/use-skills";
|
||||
import type { SkillsFilter, SkillsPageState } from "./skills-page-state";
|
||||
|
||||
export interface SkillHealthStats {
|
||||
total: number;
|
||||
missingDeps: number;
|
||||
disabled: number;
|
||||
archived: number;
|
||||
unmanaged: number;
|
||||
attention: number;
|
||||
}
|
||||
|
||||
export function displayDependencyName(dep: string): string {
|
||||
return dep.replace(/^(pip|npm):/, "");
|
||||
}
|
||||
|
||||
export function deriveSkillStats(skills: SkillInfo[]): SkillHealthStats {
|
||||
return skills.reduce<SkillHealthStats>((stats, skill) => {
|
||||
stats.total += 1;
|
||||
if (hasMissingDeps(skill)) stats.missingDeps += 1;
|
||||
if (isDisabled(skill)) stats.disabled += 1;
|
||||
if (isArchived(skill)) stats.archived += 1;
|
||||
if (isUnmanaged(skill)) stats.unmanaged += 1;
|
||||
if (needsAttention(skill)) stats.attention += 1;
|
||||
return stats;
|
||||
}, { total: 0, missingDeps: 0, disabled: 0, archived: 0, unmanaged: 0, attention: 0 });
|
||||
}
|
||||
|
||||
export function filterSkills(skills: SkillInfo[], state: Pick<SkillsPageState, "q" | "filter" | "agent">): SkillInfo[] {
|
||||
const q = state.q.trim().toLowerCase();
|
||||
return skills.filter((skill) => {
|
||||
if (q && !matchesQuery(skill, q)) return false;
|
||||
if (state.agent && !matchesAgent(skill, state.agent)) return false;
|
||||
return matchesFilter(skill, state.filter);
|
||||
});
|
||||
}
|
||||
|
||||
export function sortSkills(skills: SkillInfo[], sort: SkillsPageState["sort"]): SkillInfo[] {
|
||||
const next = [...skills];
|
||||
next.sort((a, b) => {
|
||||
if (sort === "deps") {
|
||||
const diff = (b.missing_deps?.length ?? 0) - (a.missing_deps?.length ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
if (sort === "version") {
|
||||
const diff = (b.version ?? 0) - (a.version ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function needsAttention(skill: SkillInfo): boolean {
|
||||
return hasMissingDeps(skill) || isDisabled(skill) || isArchived(skill) || isUnmanaged(skill);
|
||||
}
|
||||
|
||||
export function isUnmanaged(skill: SkillInfo): boolean {
|
||||
return !skill.is_system && (skill.manager_agents?.length ?? 0) === 0;
|
||||
}
|
||||
|
||||
export function isArchived(skill: SkillInfo): boolean {
|
||||
return skill.status === "archived";
|
||||
}
|
||||
|
||||
export function isDisabled(skill: SkillInfo): boolean {
|
||||
return skill.enabled === false;
|
||||
}
|
||||
|
||||
export function hasMissingDeps(skill: SkillInfo): boolean {
|
||||
return (skill.missing_deps?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function matchesFilter(skill: SkillInfo, filter: SkillsFilter): boolean {
|
||||
switch (filter) {
|
||||
case "attention":
|
||||
return needsAttention(skill);
|
||||
case "missing-deps":
|
||||
return hasMissingDeps(skill);
|
||||
case "disabled":
|
||||
return isDisabled(skill);
|
||||
case "archived":
|
||||
return isArchived(skill);
|
||||
case "unmanaged":
|
||||
return isUnmanaged(skill);
|
||||
case "all":
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(skill: SkillInfo, q: string): boolean {
|
||||
const agentLabels = [
|
||||
skill.creator_agent?.display_name,
|
||||
skill.creator_agent?.agent_key,
|
||||
skill.creator_agent?.id,
|
||||
...(skill.manager_agents ?? []).flatMap((agent) => [agent.display_name, agent.agent_key, agent.id]),
|
||||
];
|
||||
return [
|
||||
skill.name,
|
||||
skill.slug,
|
||||
skill.description,
|
||||
skill.author,
|
||||
...agentLabels,
|
||||
].some((value) => value?.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function matchesAgent(skill: SkillInfo, agentRef: string): boolean {
|
||||
const candidates = [
|
||||
skill.creator_agent,
|
||||
...(skill.manager_agents ?? []),
|
||||
];
|
||||
return candidates.some((agent) =>
|
||||
agent?.id === agentRef || agent?.agent_key === agentRef || agent?.display_name === agentRef,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSkillsPageState, serializeSkillsPageState } from "./skills-page-state";
|
||||
|
||||
describe("skills page state", () => {
|
||||
it("uses quiet defaults for empty params", () => {
|
||||
expect(parseSkillsPageState(new URLSearchParams())).toEqual({
|
||||
tab: "core",
|
||||
q: "",
|
||||
filter: "all",
|
||||
sort: "name",
|
||||
agent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid params", () => {
|
||||
const state = parseSkillsPageState(new URLSearchParams("tab=bad&filter=weird&sort=old&q=%20pdf%20"));
|
||||
expect(state).toMatchObject({ tab: "core", filter: "all", sort: "name", q: "pdf" });
|
||||
});
|
||||
|
||||
it("serializes non-default filters while preserving modal params", () => {
|
||||
const params = new URLSearchParams("skill=abc&detailTab=files&file=SKILL.md");
|
||||
const next = serializeSkillsPageState(params, { tab: "custom", q: "pdf", filter: "missing-deps", sort: "deps" });
|
||||
expect(next.get("tab")).toBe("custom");
|
||||
expect(next.get("q")).toBe("pdf");
|
||||
expect(next.get("filter")).toBe("missing-deps");
|
||||
expect(next.get("sort")).toBe("deps");
|
||||
expect(next.get("skill")).toBe("abc");
|
||||
expect(next.get("detailTab")).toBe("files");
|
||||
expect(next.get("file")).toBe("SKILL.md");
|
||||
});
|
||||
|
||||
it("removes default values instead of making noisy URLs", () => {
|
||||
const params = new URLSearchParams("tab=custom&q=pdf&filter=attention&sort=deps&agent=a1");
|
||||
const next = serializeSkillsPageState(params, { tab: "core", q: "", filter: "all", sort: "name", agent: null });
|
||||
expect(next.toString()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type SkillsTab = "core" | "custom";
|
||||
export type SkillsFilter = "all" | "attention" | "missing-deps" | "disabled" | "archived" | "unmanaged";
|
||||
export type SkillsSort = "name" | "deps" | "version";
|
||||
|
||||
export interface SkillsPageState {
|
||||
tab: SkillsTab;
|
||||
q: string;
|
||||
filter: SkillsFilter;
|
||||
sort: SkillsSort;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
const FILTERS = new Set<SkillsFilter>(["all", "attention", "missing-deps", "disabled", "archived", "unmanaged"]);
|
||||
const SORTS = new Set<SkillsSort>(["name", "deps", "version"]);
|
||||
|
||||
export const DEFAULT_SKILLS_PAGE_STATE: SkillsPageState = {
|
||||
tab: "core",
|
||||
q: "",
|
||||
filter: "all",
|
||||
sort: "name",
|
||||
agent: null,
|
||||
};
|
||||
|
||||
export function parseSkillsPageState(params: URLSearchParams): SkillsPageState {
|
||||
const rawFilter = params.get("filter");
|
||||
const rawSort = params.get("sort");
|
||||
const q = (params.get("q") ?? "").trim();
|
||||
const agent = (params.get("agent") ?? "").trim();
|
||||
|
||||
return {
|
||||
tab: params.get("tab") === "custom" ? "custom" : "core",
|
||||
q,
|
||||
filter: rawFilter && FILTERS.has(rawFilter as SkillsFilter) ? (rawFilter as SkillsFilter) : "all",
|
||||
sort: rawSort && SORTS.has(rawSort as SkillsSort) ? (rawSort as SkillsSort) : "name",
|
||||
agent: agent || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSkillsPageState(
|
||||
currentParams: URLSearchParams,
|
||||
updates: Partial<SkillsPageState>,
|
||||
): URLSearchParams {
|
||||
const next = new URLSearchParams(currentParams);
|
||||
const state = { ...parseSkillsPageState(currentParams), ...updates };
|
||||
|
||||
setParam(next, "tab", state.tab === "core" ? "" : state.tab);
|
||||
setParam(next, "q", state.q.trim());
|
||||
setParam(next, "filter", state.filter === "all" ? "" : state.filter);
|
||||
setParam(next, "sort", state.sort === "name" ? "" : state.sort);
|
||||
setParam(next, "agent", state.agent ?? "");
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function setParam(params: URLSearchParams, key: string, value: string) {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SkillInfo, SkillAgentRef } from "@/types/skill";
|
||||
|
||||
interface SkillAgentChipsProps {
|
||||
skill: SkillInfo;
|
||||
}
|
||||
|
||||
export function SkillAgentChips({ skill }: SkillAgentChipsProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const managers = skill.manager_agents ?? [];
|
||||
const visibleManagers = managers.slice(0, 2);
|
||||
const overflow = managers.length - visibleManagers.length;
|
||||
const unmanaged = !skill.is_system && managers.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[260px] flex-wrap gap-1">
|
||||
{skill.creator_agent && (
|
||||
<Badge variant="outline" className="max-w-full text-2xs" title={t("agents.creator")}>
|
||||
{t("agents.creator")}: {agentLabel(skill.creator_agent)}
|
||||
</Badge>
|
||||
)}
|
||||
{visibleManagers.map((agent) => (
|
||||
<Badge key={agent.id || agent.agent_key || agent.display_name} variant="secondary" className="max-w-full text-2xs" title={t("agents.managers")}>
|
||||
{agentLabel(agent)}
|
||||
</Badge>
|
||||
))}
|
||||
{overflow > 0 && <Badge variant="outline" className="text-2xs">+{overflow}</Badge>}
|
||||
{unmanaged && <Badge variant="warning" className="text-2xs">{t("status.unmanaged")}</Badge>}
|
||||
{!skill.creator_agent && managers.length === 0 && skill.author && (
|
||||
<Badge variant="outline" className="max-w-full text-2xs">{skill.author}</Badge>
|
||||
)}
|
||||
{!skill.creator_agent && managers.length === 0 && !skill.author && !unmanaged && (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function agentLabel(agent: SkillAgentRef): string {
|
||||
return agent.display_name || agent.agent_key || agent.id || "unknown";
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
interface SkillBulkActionsToolbarProps {
|
||||
selectedCount: number;
|
||||
customSelectedCount: number;
|
||||
skippedSystemCount: number;
|
||||
agentCount: number;
|
||||
loading: boolean;
|
||||
onEnable: () => void;
|
||||
@@ -17,6 +18,7 @@ interface SkillBulkActionsToolbarProps {
|
||||
export function SkillBulkActionsToolbar({
|
||||
selectedCount,
|
||||
customSelectedCount,
|
||||
skippedSystemCount,
|
||||
agentCount,
|
||||
loading,
|
||||
onEnable,
|
||||
@@ -27,15 +29,25 @@ export function SkillBulkActionsToolbar({
|
||||
}: SkillBulkActionsToolbarProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const hasSelection = selectedCount > 0;
|
||||
const customActionDisabledReason = agentCount === 0
|
||||
? t("bulk.noAgentsReason")
|
||||
: customSelectedCount === 0
|
||||
? t("bulk.customOnlyReason")
|
||||
: undefined;
|
||||
|
||||
if (!hasSelection) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-3 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2 transition-colors"
|
||||
style={{ visibility: hasSelection ? "visible" : "hidden" }}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("bulk.selected", { count: selectedCount })}
|
||||
</span>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2 transition-colors">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t("bulk.selected", { count: selectedCount })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("bulk.customAvailable", { count: customSelectedCount })}
|
||||
{skippedSystemCount > 0 ? ` · ${t("bulk.skippedSystem", { count: skippedSystemCount })}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-1" disabled={loading || !hasSelection} onClick={onEnable}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
@@ -50,6 +62,7 @@ export function SkillBulkActionsToolbar({
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
disabled={loading || customSelectedCount === 0 || agentCount === 0}
|
||||
title={customActionDisabledReason}
|
||||
onClick={onGrantAllAgents}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
@@ -60,6 +73,7 @@ export function SkillBulkActionsToolbar({
|
||||
variant="outline"
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
disabled={loading || customSelectedCount === 0}
|
||||
title={customSelectedCount === 0 ? t("bulk.customOnlyReason") : undefined}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,11 +16,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
|
||||
import { buildTree } from "./skill-file-helpers";
|
||||
import { FileBrowser } from "./skill-file-browser";
|
||||
import { parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink";
|
||||
import { getSkillAccessModeKey } from "./lib/skill-access-mode";
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
skill: SkillInfo & { content: string };
|
||||
@@ -46,6 +51,10 @@ export function SkillDetailDialog({
|
||||
}: SkillDetailDialogProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const hasFiles = !!skill.id;
|
||||
const accessModeKey = getSkillAccessModeKey(skill.visibility);
|
||||
const accessModeLabel = accessModeKey === "unknown"
|
||||
? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") })
|
||||
: t(`accessMode.${accessModeKey}`);
|
||||
|
||||
// Version state
|
||||
const [versions, setVersions] = useState<SkillVersions | null>(null);
|
||||
@@ -57,18 +66,24 @@ export function SkillDetailDialog({
|
||||
const [files, setFiles] = useState<SkillFile[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
const [fileQuery, setFileQuery] = useState("");
|
||||
|
||||
// File content state
|
||||
const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
const filteredFiles = useMemo(
|
||||
() => filterSkillFiles(files, fileQuery, activePath),
|
||||
[activePath, fileQuery, files],
|
||||
);
|
||||
const tree = useMemo(() => buildTree(filteredFiles), [filteredFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setVersions(null);
|
||||
setSelectedVersion(parseSkillDetailVersionParam(selectedVersionParam));
|
||||
setFiles([]);
|
||||
setActivePath(null);
|
||||
setFileQuery("");
|
||||
setFileContent(null);
|
||||
}, [skill.id, selectedVersionParam]);
|
||||
|
||||
@@ -155,6 +170,26 @@ export function SkillDetailDialog({
|
||||
loadFileContent(path);
|
||||
};
|
||||
|
||||
const copyDeeplink = async () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = "/skills";
|
||||
const next = new URLSearchParams(url.search);
|
||||
next.set("skill", skill.id || skill.slug || skill.name);
|
||||
next.set("detailTab", detailTab === "files" ? "files" : "content");
|
||||
if (detailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion));
|
||||
else next.delete("version");
|
||||
if (detailTab === "files" && activePath) next.set("file", activePath);
|
||||
else next.delete("file");
|
||||
url.search = next.toString();
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
toast.success(t("detail.copySuccess"));
|
||||
} catch (err) {
|
||||
toast.error(t("detail.copyFailed"), err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFiles) loadVersions();
|
||||
}, [hasFiles, loadVersions]);
|
||||
@@ -170,11 +205,15 @@ export function SkillDetailDialog({
|
||||
{skill.name}
|
||||
<Badge variant="outline">{skill.source || "file"}</Badge>
|
||||
{skill.visibility && (
|
||||
<Badge variant="secondary">{skill.visibility}</Badge>
|
||||
<Badge variant="secondary">{accessModeLabel}</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
{versions && versions.versions.length > 1 ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1" onClick={copyDeeplink}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("detail.copyLink")}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{t("detail.version")}</span>
|
||||
<Select
|
||||
value={String(headerVersion ?? versions.current)}
|
||||
@@ -193,10 +232,21 @@ export function SkillDetailDialog({
|
||||
</Select>
|
||||
</div>
|
||||
) : headerVersion ? (
|
||||
<Badge variant="outline" className="w-fit shrink-0 font-normal">
|
||||
v{headerVersion}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1" onClick={copyDeeplink}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("detail.copyLink")}
|
||||
</Button>
|
||||
<Badge variant="outline" className="w-fit shrink-0 font-normal">
|
||||
v{headerVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1" onClick={copyDeeplink}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("detail.copyLink")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground">{skill.description}</p>
|
||||
@@ -231,12 +281,25 @@ export function SkillDetailDialog({
|
||||
<MarkdownRenderer content={skill.content} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("detail.noContent")}</p>
|
||||
<div className="rounded-md border border-dashed bg-muted/20 p-6 text-sm text-muted-foreground">
|
||||
{t("detail.noContent")}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{hasFiles && (
|
||||
<TabsContent value="files" className="flex-1 overflow-hidden flex flex-col mt-2 gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SearchInput
|
||||
value={fileQuery}
|
||||
onChange={setFileQuery}
|
||||
placeholder={t("detail.fileSearch")}
|
||||
className="w-full sm:max-w-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("detail.filesShown", { shown: filteredFiles.length, total: files.length })}
|
||||
</span>
|
||||
</div>
|
||||
<FileBrowser
|
||||
tree={tree}
|
||||
filesLoading={filesLoading}
|
||||
@@ -252,3 +315,25 @@ export function SkillDetailDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function filterSkillFiles(files: SkillFile[], query: string, activePath: string | null): SkillFile[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return files;
|
||||
|
||||
const byPath = new Map(files.map((file) => [file.path, file]));
|
||||
const visible = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
const matches = file.path.toLowerCase().includes(q) || file.name.toLowerCase().includes(q) || file.path === activePath;
|
||||
if (!matches) continue;
|
||||
visible.add(file.path);
|
||||
let parentPath = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
|
||||
while (parentPath) {
|
||||
visible.add(parentPath);
|
||||
const nextSlash = parentPath.lastIndexOf("/");
|
||||
parentPath = nextSlash >= 0 ? parentPath.slice(0, nextSlash) : "";
|
||||
}
|
||||
}
|
||||
|
||||
return files.filter((file) => visible.has(file.path) || (file.isDir && byPath.has(file.path) && visible.has(file.path)));
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SKILL_ACCESS_MODE_ORDER } from "./lib/skill-access-mode";
|
||||
import type { SkillInfo } from "@/types/skill";
|
||||
|
||||
interface SkillEditDialogProps {
|
||||
@@ -32,7 +33,7 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
|
||||
const { t } = useTranslation("skills");
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description);
|
||||
const [visibility, setVisibility] = useState(skill.visibility ?? "private");
|
||||
const [accessMode, setAccessMode] = useState(skill.visibility ?? "private");
|
||||
const [tags, setTags] = useState<string[]>(skill.tags ?? []);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -40,7 +41,7 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
|
||||
useEffect(() => {
|
||||
setName(skill.name);
|
||||
setDescription(skill.description);
|
||||
setVisibility(skill.visibility ?? "private");
|
||||
setAccessMode(skill.visibility ?? "private");
|
||||
setTags(skill.tags ?? []);
|
||||
}, [skill]);
|
||||
|
||||
@@ -60,7 +61,7 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
|
||||
if (!skill.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave(skill.id, { name, description, visibility, tags });
|
||||
await onSave(skill.id, { name, description, visibility: accessMode, tags });
|
||||
onClose();
|
||||
} catch {
|
||||
// toast shown by hook — keep dialog open
|
||||
@@ -97,15 +98,15 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("edit.visibility")}</Label>
|
||||
<Select value={visibility} onValueChange={setVisibility}>
|
||||
<Label>{t("edit.accessMode")}</Label>
|
||||
<Select value={accessMode} onValueChange={setAccessMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">{t("edit.privateOption")}</SelectItem>
|
||||
<SelectItem value="internal">{t("edit.internalOption")}</SelectItem>
|
||||
<SelectItem value="public">{t("edit.publicOption")}</SelectItem>
|
||||
{SKILL_ACCESS_MODE_ORDER.map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>{t(`accessMode.${mode}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AlertTriangle, Archive, Box, CircleOff, PackageX, ShieldAlert } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SkillHealthStats } from "./lib/skills-filtering";
|
||||
import type { SkillsFilter } from "./lib/skills-page-state";
|
||||
|
||||
interface SkillHealthSummaryProps {
|
||||
stats: SkillHealthStats;
|
||||
activeFilter: SkillsFilter;
|
||||
onFilterChange: (filter: SkillsFilter) => void;
|
||||
}
|
||||
|
||||
export function SkillHealthSummary({ stats, activeFilter, onFilterChange }: SkillHealthSummaryProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const items: Array<{ filter: SkillsFilter; label: string; value: number; icon: typeof Box; tone?: string }> = [
|
||||
{ filter: "all", label: t("health.total"), value: stats.total, icon: Box },
|
||||
{ filter: "attention", label: t("health.attention"), value: stats.attention, icon: AlertTriangle, tone: "text-amber-700" },
|
||||
{ filter: "missing-deps", label: t("health.missingDeps"), value: stats.missingDeps, icon: PackageX, tone: "text-amber-700" },
|
||||
{ filter: "disabled", label: t("health.disabled"), value: stats.disabled, icon: CircleOff, tone: "text-red-700" },
|
||||
{ filter: "archived", label: t("health.archived"), value: stats.archived, icon: Archive, tone: "text-muted-foreground" },
|
||||
{ filter: "unmanaged", label: t("health.unmanaged"), value: stats.unmanaged, icon: ShieldAlert, tone: "text-orange-700" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = activeFilter === item.filter;
|
||||
return (
|
||||
<button
|
||||
key={item.filter}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(item.filter)}
|
||||
className={cn(
|
||||
"flex min-h-16 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left transition-colors",
|
||||
active ? "border-primary bg-primary/5 text-primary" : "bg-background hover:bg-muted/40",
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className={cn("text-lg font-semibold leading-tight", item.tone)}>{item.value}</div>
|
||||
</div>
|
||||
<Icon className={cn("h-4 w-4 shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SkillInfo } from "./hooks/use-skills";
|
||||
import { displayDependencyName, hasMissingDeps, isArchived, isDisabled } from "./lib/skills-filtering";
|
||||
|
||||
interface SkillStatusBadgesProps {
|
||||
skill: SkillInfo;
|
||||
}
|
||||
|
||||
export function SkillStatusBadges({ skill }: SkillStatusBadgesProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const archived = isArchived(skill);
|
||||
const disabled = isDisabled(skill);
|
||||
const missing = skill.missing_deps ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[220px] flex-wrap gap-1">
|
||||
<Badge variant={archived ? "warning" : "success"} className="text-2xs">
|
||||
{archived ? t("deps.statusArchived") : t("deps.statusActive")}
|
||||
</Badge>
|
||||
{disabled && <Badge variant="destructive" className="text-2xs">{t("toggle.disabled")}</Badge>}
|
||||
{hasMissingDeps(skill) && (
|
||||
<Badge
|
||||
variant="warning"
|
||||
className="text-2xs"
|
||||
title={missing.map(displayDependencyName).join(", ")}
|
||||
>
|
||||
{t("status.missingDepsCount", { count: missing.length })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,11 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SkillTenantOverride } from "./skill-tenant-override";
|
||||
import { SkillAgentChips } from "./skill-agent-chips";
|
||||
import { SkillStatusBadges } from "./skill-status-badges";
|
||||
import { getSkillAccessModeBadgeVariant, getSkillAccessModeKey } from "./lib/skill-access-mode";
|
||||
import type { SkillInfo } from "./hooks/use-skills";
|
||||
|
||||
const visibilityColor: Record<string, string> = {
|
||||
public: "default",
|
||||
internal: "secondary",
|
||||
private: "outline",
|
||||
};
|
||||
|
||||
interface SkillTableRowProps {
|
||||
skill: SkillInfo;
|
||||
tab: "core" | "custom";
|
||||
@@ -25,21 +22,25 @@ interface SkillTableRowProps {
|
||||
onManageGrants: (skill: SkillInfo) => void;
|
||||
onDelete: (skill: SkillInfo) => void;
|
||||
onToggle: (skill: SkillInfo, enabled: boolean) => void;
|
||||
onCycleVisibility: (skill: SkillInfo) => void;
|
||||
onCycleAccessMode: (skill: SkillInfo) => void;
|
||||
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
|
||||
onDeleteTenantConfig: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Single row in the skills table with inline status, visibility, and action controls. */
|
||||
/** Single row in the skills table with inline status, access mode, and action controls. */
|
||||
export function SkillTableRow({
|
||||
skill, tab, hasTenantScope, toggling, selected, onToggleSelect,
|
||||
onView, onEdit, onManageGrants, onDelete, onToggle, onCycleVisibility,
|
||||
onView, onEdit, onManageGrants, onDelete, onToggle, onCycleAccessMode,
|
||||
onSetTenantConfig, onDeleteTenantConfig,
|
||||
}: SkillTableRowProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const isArchived = skill.status === "archived";
|
||||
const isDisabled = skill.enabled === false;
|
||||
const hasMissing = (skill.missing_deps?.length ?? 0) > 0;
|
||||
const accessModeKey = getSkillAccessModeKey(skill.visibility);
|
||||
const accessModeLabel = accessModeKey === "unknown"
|
||||
? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") })
|
||||
: t(`accessMode.${accessModeKey}`);
|
||||
const accessModeVariant = getSkillAccessModeBadgeVariant(skill.visibility);
|
||||
|
||||
return (
|
||||
<tr className={cn("border-b last:border-0 hover:bg-muted/30", selected && "bg-primary/5", (isArchived || isDisabled) && "opacity-60")}>
|
||||
@@ -77,63 +78,27 @@ export function SkillTableRow({
|
||||
</td>
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex max-w-[220px] flex-col gap-1">
|
||||
{skill.author && <span className="truncate">{skill.author}</span>}
|
||||
{skill.creator_agent && (
|
||||
<span className="truncate text-2xs">
|
||||
{t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id}
|
||||
</span>
|
||||
)}
|
||||
{skill.manager_agents && skill.manager_agents.length > 0 ? (
|
||||
<span className="truncate text-2xs">
|
||||
{t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")}
|
||||
</span>
|
||||
) : !skill.author && !skill.creator_agent ? (
|
||||
<span>—</span>
|
||||
) : null}
|
||||
</div>
|
||||
<SkillAgentChips skill={skill} />
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-2xs w-fit",
|
||||
isArchived
|
||||
? "border-amber-500 text-amber-600 dark:border-amber-600 dark:text-amber-400"
|
||||
: "border-emerald-500 text-emerald-600 dark:border-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{isArchived ? t("deps.statusArchived") : t("deps.statusActive")}
|
||||
</Badge>
|
||||
{hasMissing && (() => {
|
||||
const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, ""));
|
||||
const shown = deps.slice(0, 3);
|
||||
const rest = deps.length - shown.length;
|
||||
return (
|
||||
<span className="text-2xs text-amber-600 dark:text-amber-400 leading-tight">
|
||||
{shown.join(", ")}{rest > 0 && `, +${rest}`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<SkillStatusBadges skill={skill} />
|
||||
</td>
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3">
|
||||
{skill.visibility && (
|
||||
skill.id ? (
|
||||
<button type="button" onClick={() => onCycleVisibility(skill)} title={t("visibility.clickToCycle")}>
|
||||
<button type="button" onClick={() => onCycleAccessMode(skill)} title={t("accessMode.clickToCycle")}>
|
||||
<Badge
|
||||
variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}
|
||||
variant={accessModeVariant}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{skill.visibility}
|
||||
{accessModeLabel}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
|
||||
{skill.visibility}
|
||||
<Badge variant={accessModeVariant}>
|
||||
{accessModeLabel}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
@@ -161,10 +126,12 @@ export function SkillTableRow({
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(skill)} className="gap-1">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("edit.title")}</span>
|
||||
</Button>
|
||||
{!skill.is_system && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onManageGrants(skill)} className="gap-1" title={t("grants.manage")}>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("grants.manage")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!skill.is_system && (
|
||||
@@ -174,6 +141,7 @@ export function SkillTableRow({
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("delete.confirmLabel")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { createSkillSubZip } from "./lib/create-skill-sub-zip";
|
||||
import { resolveUploadSkills } from "./lib/resolve-upload-skills";
|
||||
import { summarizeUploadEntries } from "./lib/skill-upload-summary";
|
||||
import { uniqueId } from "@/lib/utils";
|
||||
import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills";
|
||||
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
|
||||
@@ -238,9 +240,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
// Derived counts (skill level, not file level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const allSkills = entries.flatMap((e) => e.skills);
|
||||
const actionableCount = allSkills.filter((s) => s.status === "valid").length;
|
||||
const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length;
|
||||
const uploadSummary = summarizeUploadEntries(entries);
|
||||
const actionableCount = uploadSummary.valid;
|
||||
const allCurrentAgentsSelected = agents.length > 0 && managerAgentIds.length === agents.length;
|
||||
|
||||
const toggleManagerAgent = (id: string) => {
|
||||
@@ -326,9 +327,12 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{t("upload.managerAgentsHelp")}</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={toggleAllAgents} disabled={agents.length === 0}>
|
||||
{allCurrentAgentsSelected ? t("upload.clearAgents") : t("upload.selectAllAgents")}
|
||||
{allCurrentAgentsSelected ? t("upload.clearAgents") : t("upload.selectAllCurrentAgents")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("upload.selectedAgentsCount", { selected: managerAgentIds.length, total: agents.length })}
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border">
|
||||
{agents.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">{t("upload.noAgents")}</p>
|
||||
@@ -352,13 +356,18 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
{/* Summary line */}
|
||||
{entries.length > 0 && !done && !uploading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("upload.validCount", { valid: actionableCount, total: allSkills.length })}
|
||||
{t("upload.validCount", { valid: actionableCount, total: uploadSummary.total })}
|
||||
</p>
|
||||
)}
|
||||
{done && (
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("upload.successCount", { success: successCount, total: allSkills.filter((s) => s.status !== "unchanged" && s.status !== "invalid").length })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 text-sm">
|
||||
<Badge variant="success">{t("upload.summaryUploaded", { count: uploadSummary.uploaded })}</Badge>
|
||||
<Badge variant="warning">{t("upload.summaryWarnings", { count: uploadSummary.warnings })}</Badge>
|
||||
<Badge variant="outline">{t("upload.summaryUnchanged", { count: uploadSummary.unchanged })}</Badge>
|
||||
<Badge variant={uploadSummary.failed + uploadSummary.invalid > 0 ? "destructive" : "outline"}>
|
||||
{t("upload.summaryFailed", { count: uploadSummary.failed + uploadSummary.invalid })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import type { SkillsFilter, SkillsPageState, SkillsSort } from "./lib/skills-page-state";
|
||||
|
||||
interface SkillsFilterBarProps {
|
||||
state: SkillsPageState;
|
||||
onChange: (updates: Partial<SkillsPageState>) => void;
|
||||
}
|
||||
|
||||
export function SkillsFilterBar({ state, onChange }: SkillsFilterBarProps) {
|
||||
const { t } = useTranslation("skills");
|
||||
const hasFilters = !!state.q || state.filter !== "all" || state.sort !== "name" || !!state.agent;
|
||||
const filterOptions: SkillsFilter[] = ["all", "attention", "missing-deps", "disabled", "archived", "unmanaged"];
|
||||
const sortOptions: SkillsSort[] = ["name", "deps", "version"];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
value={state.q}
|
||||
onChange={(q) => onChange({ q })}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full md:max-w-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={state.filter} onValueChange={(filter) => onChange({ filter: filter as SkillsFilter })}>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<SelectValue aria-label={t("filters.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions.map((filter) => (
|
||||
<SelectItem key={filter} value={filter}>{t(`filters.${filter}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={state.sort} onValueChange={(sort) => onChange({ sort: sort as SkillsSort })}>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue aria-label={t("sort.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortOptions.map((sort) => (
|
||||
<SelectItem key={sort} value={sort}>{t(`sort.${sort}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => onChange({ q: "", filter: "all", sort: "name", agent: null })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, lazy, Suspense, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, RefreshCw, Upload, ScanSearch, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { SearchInput } from "@/components/shared/search-input";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
@@ -16,6 +15,8 @@ import { SkillDetailDialog } from "./skill-detail-dialog";
|
||||
import { SkillEditDialog } from "./skill-edit-dialog";
|
||||
import { SkillAgentGrantsDialog } from "./skill-agent-grants-dialog";
|
||||
import { SkillBulkActionsToolbar } from "./skill-bulk-actions-toolbar";
|
||||
import { SkillHealthSummary } from "./skill-health-summary";
|
||||
import { SkillsFilterBar } from "./skills-filter-bar";
|
||||
|
||||
const SkillUploadDialog = lazy(() =>
|
||||
import("./skill-upload-dialog").then((m) => ({ default: m.SkillUploadDialog }))
|
||||
@@ -28,6 +29,13 @@ import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { usePagination } from "@/hooks/use-pagination";
|
||||
import { useTenants } from "@/hooks/use-tenants";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { deriveSkillStats, filterSkills, sortSkills } from "./lib/skills-filtering";
|
||||
import {
|
||||
parseSkillsPageState,
|
||||
serializeSkillsPageState,
|
||||
type SkillsPageState,
|
||||
} from "./lib/skills-page-state";
|
||||
import { getNextSkillAccessMode } from "./lib/skill-access-mode";
|
||||
|
||||
const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001";
|
||||
|
||||
@@ -43,15 +51,17 @@ export function SkillsPage() {
|
||||
setTenantConfig, deleteTenantConfig,
|
||||
} = useSkills();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const routeParams = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { runtimes } = useRuntimes();
|
||||
const { currentTenantId } = useTenants();
|
||||
const { agents } = useAgents();
|
||||
const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID;
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
|
||||
const urlTab = params.get("tab") === "custom" ? "custom" : "core";
|
||||
const tab: Tab = urlTab;
|
||||
const [search, setSearch] = useState("");
|
||||
const pageState = useMemo(() => parseSkillsPageState(params), [params]);
|
||||
const tab: Tab = pageState.tab;
|
||||
const skillRef = params.get("skill");
|
||||
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<SkillInfo | null>(null);
|
||||
@@ -68,27 +78,33 @@ export function SkillsPage() {
|
||||
const coreSkills = useMemo(() => skills.filter((s: SkillInfo) => s.is_system), [skills]);
|
||||
const customSkills = useMemo(() => skills.filter((s: SkillInfo) => !s.is_system), [skills]);
|
||||
const tabSkills = tab === "core" ? coreSkills : customSkills;
|
||||
const healthStats = useMemo(() => deriveSkillStats(tabSkills), [tabSkills]);
|
||||
const allMissing = useMemo(
|
||||
() => [...new Set(tabSkills.flatMap((s: SkillInfo) => s.missing_deps ?? []))],
|
||||
[tabSkills],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() => tabSkills.filter(
|
||||
(s: SkillInfo) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[search, tabSkills],
|
||||
() => sortSkills(filterSkills(tabSkills, pageState), pageState.sort),
|
||||
[pageState, tabSkills],
|
||||
);
|
||||
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
|
||||
const selectedSkills = filtered.filter((skill) => skill.id && selectedIds.has(skill.id));
|
||||
const selectedCustomSkills = selectedSkills.filter((skill) => !skill.is_system);
|
||||
const skippedSystemCount = selectedSkills.length - selectedCustomSkills.length;
|
||||
const pageSelectableIds = pageItems.map((skill) => skill.id).filter((id): id is string => !!id);
|
||||
const allPageSelected = pageSelectableIds.length > 0 && pageSelectableIds.every((id) => selectedIds.has(id));
|
||||
const somePageSelected = pageSelectableIds.some((id) => selectedIds.has(id)) && !allPageSelected;
|
||||
|
||||
useEffect(() => { resetPage(); }, [search, tab, resetPage]);
|
||||
useEffect(() => { setSelectedIds(new Set()); }, [search, tab]);
|
||||
useEffect(() => {
|
||||
const routeSkillId = routeParams.id;
|
||||
if (!routeSkillId) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.set("skill", routeSkillId);
|
||||
navigate({ pathname: "/skills", search: toSearchString(next) }, { replace: true });
|
||||
}, [navigate, params, routeParams.id]);
|
||||
|
||||
useEffect(() => { resetPage(); }, [pageState.q, pageState.filter, pageState.sort, pageState.agent, tab, resetPage]);
|
||||
useEffect(() => { setSelectedIds(new Set()); }, [pageState.q, pageState.filter, pageState.sort, pageState.agent, tab]);
|
||||
useEffect(() => {
|
||||
setSelectedIds((current) => {
|
||||
const valid = new Set(filtered.map((skill) => skill.id).filter(Boolean));
|
||||
@@ -97,6 +113,10 @@ export function SkillsPage() {
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const setPageState = (updates: Partial<SkillsPageState>) => {
|
||||
setParams(serializeSkillsPageState(params, updates), { replace: true });
|
||||
};
|
||||
|
||||
const setParamValues = (updates: Record<string, string | null>) => {
|
||||
const next = new URLSearchParams(params);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
@@ -108,7 +128,8 @@ export function SkillsPage() {
|
||||
|
||||
const setTab = (nextTab: Tab) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set("tab", nextTab);
|
||||
if (nextTab === "core") next.delete("tab");
|
||||
else next.set("tab", nextTab);
|
||||
next.delete("skill");
|
||||
next.delete("detailTab");
|
||||
next.delete("version");
|
||||
@@ -122,13 +143,14 @@ export function SkillsPage() {
|
||||
next.delete("detailTab");
|
||||
next.delete("version");
|
||||
next.delete("file");
|
||||
setParams(next, { replace: true });
|
||||
navigate({ pathname: "/skills", search: toSearchString(next) }, { replace: true });
|
||||
setSelectedSkill(null);
|
||||
};
|
||||
|
||||
const handleViewSkill = async (skill: SkillInfo) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set("tab", skill.is_system ? "core" : "custom");
|
||||
if (skill.is_system) next.delete("tab");
|
||||
else next.set("tab", "custom");
|
||||
next.set("skill", skill.id || skill.slug || skill.name);
|
||||
next.set("detailTab", "content");
|
||||
next.delete("version");
|
||||
@@ -137,7 +159,6 @@ export function SkillsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const skillRef = params.get("skill");
|
||||
if (!skillRef) {
|
||||
setSelectedSkill(null);
|
||||
return;
|
||||
@@ -147,13 +168,11 @@ export function SkillsPage() {
|
||||
if (!cancelled) setSelectedSkill(detail);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [params, getSkill]);
|
||||
}, [skillRef, getSkill]);
|
||||
|
||||
const handleCycleVisibility = async (skill: SkillInfo) => {
|
||||
const handleCycleAccessMode = async (skill: SkillInfo) => {
|
||||
if (!skill.id) return;
|
||||
const order = ["private", "internal", "public"] as const;
|
||||
const idx = order.indexOf(skill.visibility as typeof order[number]);
|
||||
await updateSkill(skill.id, { visibility: order[(idx + 1) % order.length] });
|
||||
await updateSkill(skill.id, { visibility: getNextSkillAccessMode(skill.visibility) });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -305,6 +324,7 @@ export function SkillsPage() {
|
||||
<SkillBulkActionsToolbar
|
||||
selectedCount={selectedSkills.length}
|
||||
customSelectedCount={selectedCustomSkills.length}
|
||||
skippedSystemCount={skippedSystemCount}
|
||||
agentCount={agents.length}
|
||||
loading={bulkLoading || deleteLoading}
|
||||
onEnable={() => handleBulkToggle(true)}
|
||||
@@ -316,7 +336,14 @@ export function SkillsPage() {
|
||||
|
||||
<div className="mt-4">
|
||||
<MissingDepsPanel missing={allMissing} onInstallItem={installSingleDep} runtimes={tab === "core" ? runtimes : undefined} />
|
||||
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
|
||||
<div className="space-y-3">
|
||||
<SkillHealthSummary
|
||||
stats={healthStats}
|
||||
activeFilter={pageState.filter}
|
||||
onFilterChange={(filter) => setPageState({ filter })}
|
||||
/>
|
||||
<SkillsFilterBar state={pageState} onChange={setPageState} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -325,8 +352,8 @@ export function SkillsPage() {
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Zap}
|
||||
title={search ? t("noMatchTitle") : t("emptyTitle")}
|
||||
description={search ? t("noMatchDescription") : t("emptyDescription")}
|
||||
title={pageState.q || pageState.filter !== "all" ? t("noMatchTitle") : t("emptyTitle")}
|
||||
description={pageState.q || pageState.filter !== "all" ? t("noMatchDescription") : t("emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
@@ -347,7 +374,7 @@ export function SkillsPage() {
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.description")}</th>
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.agents")}</th>}
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.visibility")}</th>}
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.accessMode")}</th>}
|
||||
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -366,7 +393,7 @@ export function SkillsPage() {
|
||||
onManageGrants={setGrantsTarget}
|
||||
onDelete={setDeleteTarget}
|
||||
onToggle={handleToggle}
|
||||
onCycleVisibility={handleCycleVisibility}
|
||||
onCycleAccessMode={handleCycleAccessMode}
|
||||
onSetTenantConfig={handleSetTenantConfig}
|
||||
onDeleteTenantConfig={handleDeleteTenantConfig}
|
||||
/>
|
||||
@@ -445,3 +472,8 @@ export function SkillsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toSearchString(params: URLSearchParams): string {
|
||||
const serialized = params.toString();
|
||||
return serialized ? `?${serialized}` : "";
|
||||
}
|
||||
Reference in new issue
Block a user