feat(web): add skill marketplace hub and registry install flow (#2246)

- add backend APIs for searching and installing registry skills, including origin metadata and concurrency-safe workspace writes
- introduce /agent/hub as the default agent entry with marketplace search and install UI
- refactor the skills and tools pages with filtering, dialogs, detail views, import validation, and updated i18n
- expand backend tests for search, install, import, rollback, and concurrent requests
This commit is contained in:
wenjie
2026-04-01 19:25:31 +08:00
committed by GitHub
parent a9c76eca21
commit c0464bdd5d
34 changed files with 4816 additions and 600 deletions
+833 -54
View File
@@ -1,40 +1,115 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
type skillSupportResponse struct {
Skills []skills.SkillInfo `json:"skills"`
Skills []skillSupportItem `json:"skills"`
}
type skillSupportItem struct {
Name string `json:"name"`
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
OriginKind string `json:"origin_kind"`
RegistryName string `json:"registry_name,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at,omitempty"`
}
type skillDetailResponse struct {
Name string `json:"name"`
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
Content string `json:"content"`
skillSupportItem
Content string `json:"content"`
}
type skillSearchResultItem struct {
Score float64 `json:"score"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
Version string `json:"version"`
RegistryName string `json:"registry_name"`
URL string `json:"url,omitempty"`
Installed bool `json:"installed"`
InstalledName string `json:"installed_name,omitempty"`
}
type skillSearchResponse struct {
Results []skillSearchResultItem `json:"results"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset int `json:"next_offset,omitempty"`
HasMore bool `json:"has_more"`
}
type installSkillRequest struct {
Slug string `json:"slug"`
Registry string `json:"registry"`
Version string `json:"version,omitempty"`
Force bool `json:"force,omitempty"`
}
type installSkillResponse struct {
Status string `json:"status"`
Slug string `json:"slug"`
Registry string `json:"registry"`
Version string `json:"version"`
Summary string `json:"summary,omitempty"`
IsSuspicious bool `json:"is_suspicious,omitempty"`
InstalledSkill *skillSupportItem `json:"skill,omitempty"`
}
type installedSkillOriginMeta struct {
Version int `json:"version"`
OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry,omitempty"`
Slug string `json:"slug,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
InstalledAt int64 `json:"installed_at"`
}
var (
skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
persistSkillOriginMeta = writeSkillOriginMeta
workspaceSkillWriteMu sync.Mutex
errImportedSkillExists = errors.New("skill already exists")
)
const (
maxImportedSkillSize = 1 << 20
maxRegistrySearchFanout = 1000
)
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/skills", h.handleListSkills)
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
mux.HandleFunc("GET /api/skills/search", h.handleSearchSkills)
mux.HandleFunc("POST /api/skills/install", h.handleInstallSkill)
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
}
@@ -46,11 +121,15 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
items, err := buildSkillSupportItems(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSupportResponse{
Skills: loader.ListSkills(),
Skills: items,
})
}
@@ -61,16 +140,18 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
return
}
loader := newSkillsLoader(cfg.WorkspacePath())
skillItems, err := buildSkillSupportItems(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to build skill list: %v", err), http.StatusInternalServerError)
return
}
name := r.PathValue("name")
allSkills := loader.ListSkills()
for _, skill := range allSkills {
if skill.Name != name {
for _, skillItem := range skillItems {
if skillItem.Name != name {
continue
}
content, err := loadSkillContent(skill.Path)
content, err := loadSkillContent(skillItem.Path)
if err != nil {
http.Error(w, "Skill content not found", http.StatusNotFound)
return
@@ -78,11 +159,8 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillDetailResponse{
Name: skill.Name,
Path: skill.Path,
Source: skill.Source,
Description: skill.Description,
Content: content,
skillSupportItem: skillItem,
Content: content,
})
return
}
@@ -90,6 +168,266 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Skill not found", http.StatusNotFound)
}
func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
cfg, loadErr := config.LoadConfig(h.configPath)
if loadErr != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
return
}
if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil {
http.Error(w, registryErr.Error(), http.StatusBadRequest)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
limit := 20
if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" {
parsedLimit, parseErr := strconv.Atoi(rawLimit)
if parseErr != nil || parsedLimit < 1 || parsedLimit > 50 {
http.Error(w, "limit must be between 1 and 50", http.StatusBadRequest)
return
}
limit = parsedLimit
}
offset := 0
if rawOffset := strings.TrimSpace(r.URL.Query().Get("offset")); rawOffset != "" {
parsedOffset, parseErr := strconv.Atoi(rawOffset)
if parseErr != nil || parsedOffset < 0 {
http.Error(w, "offset must be 0 or greater", http.StatusBadRequest)
return
}
offset = parsedOffset
}
installedSkills, err := buildOccupiedWorkspaceSkillsByDirectory(cfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to inspect installed skills: %v", err), http.StatusInternalServerError)
return
}
if query == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skillSearchResponse{
Results: []skillSearchResultItem{},
Limit: limit,
Offset: offset,
HasMore: false,
})
return
}
registryMgr := newSkillsRegistryManager(cfg)
searchLimit := offset + limit + 1
if searchLimit > maxRegistrySearchFanout {
searchLimit = maxRegistrySearchFanout
}
results, err := registryMgr.SearchAll(r.Context(), query, searchLimit)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to search skills: %v", err), http.StatusBadGateway)
return
}
if offset > len(results) {
offset = len(results)
}
end := offset + limit
if end > len(results) {
end = len(results)
}
pageResults := results[offset:end]
response := make([]skillSearchResultItem, 0, len(pageResults))
for _, result := range pageResults {
installedSkill, installed := installedSkills[result.Slug]
item := skillSearchResultItem{
Score: result.Score,
Slug: result.Slug,
DisplayName: result.DisplayName,
Summary: result.Summary,
Version: result.Version,
RegistryName: result.RegistryName,
URL: registrySkillURL(cfg, result.RegistryName, result.Slug),
Installed: installed,
}
if installed {
item.InstalledName = installedSkill.Name
}
response = append(response, item)
}
w.Header().Set("Content-Type", "application/json")
nextOffset := 0
hasMore := len(results) > end
if hasMore {
nextOffset = end
}
json.NewEncoder(w).Encode(skillSearchResponse{
Results: response,
Limit: limit,
Offset: offset,
NextOffset: nextOffset,
HasMore: hasMore,
})
}
func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
cfg, loadErr := config.LoadConfig(h.configPath)
if loadErr != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError)
return
}
if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil {
http.Error(w, registryErr.Error(), http.StatusBadRequest)
return
}
var req installSkillRequest
if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest)
return
}
req.Slug = strings.TrimSpace(req.Slug)
req.Registry = strings.TrimSpace(req.Registry)
req.Version = strings.TrimSpace(req.Version)
if validateErr := utils.ValidateSkillIdentifier(req.Slug); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid slug %q: error: %s", req.Slug, validateErr.Error()),
http.StatusBadRequest,
)
return
}
if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid registry %q: error: %s", req.Registry, validateErr.Error()),
http.StatusBadRequest,
)
return
}
registryMgr := newSkillsRegistryManager(cfg)
registry := registryMgr.GetRegistry(req.Registry)
if registry == nil {
http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest)
return
}
workspace := cfg.WorkspacePath()
skillsRoot := filepath.Join(workspace, "skills")
targetDir := filepath.Join(workspace, "skills", req.Slug)
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
targetExists := false
if _, statErr := os.Stat(targetDir); statErr == nil {
targetExists = true
} else if !os.IsNotExist(statErr) {
http.Error(w, fmt.Sprintf("Failed to inspect install target: %v", statErr), http.StatusInternalServerError)
return
}
if !req.Force && targetExists {
http.Error(w, fmt.Sprintf("skill %q already installed at %s", req.Slug, targetDir), http.StatusConflict)
return
}
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", err), http.StatusInternalServerError)
return
}
stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, req.Slug)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError)
return
}
defer os.RemoveAll(stagedWorkspaceRoot)
result, err := registry.DownloadAndInstall(r.Context(), req.Slug, req.Version, stagedTargetDir)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to install skill: %v", err), http.StatusBadGateway)
return
}
if result.IsMalwareBlocked {
http.Error(
w,
fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", req.Slug),
http.StatusForbidden,
)
return
}
if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, req.Slug) == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug),
http.StatusBadGateway,
)
return
}
installedAt := time.Now().UnixMilli()
if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: registry.Name(),
Slug: req.Slug,
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
InstalledVersion: result.Version,
InstalledAt: installedAt,
}); err != nil {
http.Error(w, fmt.Sprintf("Failed to persist skill metadata: %v", err), http.StatusInternalServerError)
return
}
if err := commitStagedSkillInstall(
stagedWorkspaceRoot,
stagedTargetDir,
targetDir,
req.Force && targetExists,
); err != nil {
http.Error(w, fmt.Sprintf("Failed to activate installed skill: %v", err), http.StatusInternalServerError)
return
}
validatedSkill := findWorkspaceSkillByDirectory(cfg, req.Slug)
if validatedSkill == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: activated archive for %q is not a valid skill", req.Slug),
http.StatusBadGateway,
)
return
}
installedSkill := &skillSupportItem{
Name: validatedSkill.Name,
Path: validatedSkill.Path,
Source: validatedSkill.Source,
Description: validatedSkill.Description,
OriginKind: "third_party",
RegistryName: registry.Name(),
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
InstalledVersion: result.Version,
InstalledAt: installedAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(installSkillResponse{
Status: "ok",
Slug: req.Slug,
Registry: registry.Name(),
Version: result.Version,
Summary: result.Summary,
IsSuspicious: result.IsSuspicious,
InstalledSkill: installedSkill,
})
}
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
@@ -110,54 +448,26 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
}
defer uploadedFile.Close()
content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
return
}
if len(content) > 1<<20 {
if len(content) > maxImportedSkillSize {
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
return
}
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
importedSkill, statusCode, err := importUploadedSkill(cfg, fileHeader.Filename, content)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), statusCode)
return
}
content = normalizeImportedSkillContent(content, skillName)
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(skillDir); err == nil {
http.Error(w, "skill already exists", http.StatusConflict)
return
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(skillFile, content, 0o644); err != nil {
http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError)
return
}
loader := newSkillsLoader(workspace)
for _, skill := range loader.ListSkills() {
if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(skill)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"name": skillName,
"path": skillFile,
})
json.NewEncoder(w).Encode(importedSkill)
}
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
@@ -169,6 +479,9 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
loader := newSkillsLoader(cfg.WorkspacePath())
name := r.PathValue("name")
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
for _, skill := range loader.ListSkills() {
if skill.Name != name {
continue
@@ -197,12 +510,274 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
)
}
func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager {
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
return skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
}
func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error {
if !cfg.Tools.IsToolEnabled("skills") {
return fmt.Errorf("tools.skills is disabled")
}
if !cfg.Tools.IsToolEnabled(toolName) {
return fmt.Errorf("%s is disabled", toolName)
}
return nil
}
func buildSkillSupportItems(cfg *config.Config) ([]skillSupportItem, error) {
rawSkills := newSkillsLoader(cfg.WorkspacePath()).ListSkills()
items := make([]skillSupportItem, 0, len(rawSkills))
for _, skill := range rawSkills {
item, err := enrichSkillInfo(cfg, skill)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func buildWorkspaceSkillItemsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
result := make(map[string]skillSupportItem)
items, err := buildSkillSupportItems(cfg)
if err != nil {
return nil, err
}
for _, skill := range items {
if skill.Source != "workspace" {
continue
}
dir := filepath.Base(filepath.Dir(skill.Path))
if dir == "" {
continue
}
result[dir] = skill
}
return result, nil
}
func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]skillSupportItem, error) {
result := make(map[string]skillSupportItem)
items, err := buildSkillSupportItems(cfg)
if err != nil {
return nil, err
}
for _, skill := range items {
if skill.Source != "workspace" {
continue
}
key := filepath.Base(filepath.Dir(skill.Path))
if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" {
key = meta.Slug
}
if key == "" {
continue
}
result[key] = skill
}
return result, nil
}
func findWorkspaceSkillByDirectory(cfg *config.Config, directory string) *skillSupportItem {
items, err := buildWorkspaceSkillItemsByDirectory(cfg)
if err != nil {
return nil
}
skill, ok := items[directory]
if !ok {
return nil
}
return &skill
}
func findWorkspaceSkillInfoByDirectory(workspace, directory string) *skills.SkillInfo {
loader := skills.NewSkillsLoader(workspace, "", "")
for _, skill := range loader.ListSkills() {
if skill.Source != "workspace" {
continue
}
if filepath.Base(filepath.Dir(skill.Path)) != directory {
continue
}
skillCopy := skill
return &skillCopy
}
return nil
}
func createStagedSkillInstall(skillsRoot, slug string) (string, string, error) {
stagedWorkspaceRoot, err := os.MkdirTemp(skillsRoot, "."+slug+"-install-*")
if err != nil {
return "", "", err
}
stagedTargetDir := filepath.Join(stagedWorkspaceRoot, "skills", slug)
return stagedWorkspaceRoot, stagedTargetDir, nil
}
func commitStagedSkillInstall(stagedWorkspaceRoot, stagedTargetDir, targetDir string, replaceExisting bool) error {
if !replaceExisting {
return os.Rename(stagedTargetDir, targetDir)
}
backupDir, err := reserveTempDirPath(filepath.Dir(targetDir), "."+filepath.Base(targetDir)+"-backup-*")
if err != nil {
return err
}
if err := os.Rename(targetDir, backupDir); err != nil {
return fmt.Errorf("failed to move existing skill aside: %w", err)
}
if err := os.Rename(stagedTargetDir, targetDir); err != nil {
if rollbackErr := os.Rename(backupDir, targetDir); rollbackErr != nil {
return fmt.Errorf("failed to activate replacement: %w (rollback failed: %v)", err, rollbackErr)
}
return fmt.Errorf("failed to activate replacement: %w", err)
}
_ = os.RemoveAll(backupDir)
_ = os.RemoveAll(stagedWorkspaceRoot)
return nil
}
func reserveTempDirPath(parent, pattern string) (string, error) {
tempDir, err := os.MkdirTemp(parent, pattern)
if err != nil {
return "", err
}
if err := os.Remove(tempDir); err != nil {
return "", err
}
return tempDir, nil
}
func enrichSkillInfo(cfg *config.Config, skill skills.SkillInfo) (skillSupportItem, error) {
item := skillSupportItem{
Name: skill.Name,
Path: skill.Path,
Source: skill.Source,
Description: skill.Description,
OriginKind: "builtin",
}
switch skill.Source {
case "builtin":
item.OriginKind = "builtin"
case "global":
item.OriginKind = "builtin"
case "workspace":
meta, err := readInstalledSkillOriginMeta(skill.Path)
if err == nil && meta != nil {
switch meta.OriginKind {
case "manual":
item.OriginKind = "manual"
item.InstalledAt = meta.InstalledAt
case "third_party":
item.OriginKind = "third_party"
item.RegistryName = meta.Registry
item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
item.InstalledVersion = meta.InstalledVersion
item.InstalledAt = meta.InstalledAt
default:
if meta.Registry != "" || meta.Slug != "" || meta.InstalledVersion != "" {
item.OriginKind = "third_party"
item.RegistryName = meta.Registry
item.RegistryURL = registrySkillURLFromMeta(cfg, meta)
item.InstalledVersion = meta.InstalledVersion
item.InstalledAt = meta.InstalledAt
} else {
item.OriginKind = "builtin"
item.InstalledAt = meta.InstalledAt
}
}
} else {
item.OriginKind = "builtin"
}
default:
item.OriginKind = "builtin"
}
return item, nil
}
func readInstalledSkillOriginMeta(skillPath string) (*installedSkillOriginMeta, error) {
metaPath := filepath.Join(filepath.Dir(skillPath), ".skill-origin.json")
data, err := os.ReadFile(metaPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var meta installedSkillOriginMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil, err
}
return &meta, nil
}
func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error {
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
func registrySkillURL(cfg *config.Config, registryName, slug string) string {
switch registryName {
case "clawhub":
baseURL := strings.TrimRight(cfg.Tools.Skills.Registries.ClawHub.BaseURL, "/")
if baseURL == "" {
baseURL = "https://clawhub.ai"
}
return baseURL + "/skills/" + url.PathEscape(slug)
default:
return ""
}
}
func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string {
if meta == nil || meta.Slug == "" {
return ""
}
if meta.RegistryURL != "" {
return meta.RegistryURL
}
if cfg == nil || meta.Registry == "" {
return ""
}
return registrySkillURL(cfg, meta.Registry, meta.Slug)
}
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
return normalizeImportedSkillNameWithHint(filename, "", content)
}
func normalizeImportedSkillNameWithHint(filename, directoryHint string, content []byte) (string, error) {
rawContent := strings.ReplaceAll(string(content), "\r\n", "\n")
rawContent = strings.ReplaceAll(rawContent, "\r", "\n")
metadata, _ := extractImportedSkillMetadata(rawContent)
raw := strings.TrimSpace(metadata["name"])
if raw == "" {
raw = strings.TrimSpace(directoryHint)
}
if raw == "" {
raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
}
@@ -259,6 +834,210 @@ func normalizeImportedSkillContent(content []byte, skillName string) []byte {
return []byte(builder.String())
}
func importUploadedSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
if isImportedSkillArchive(filename, content) {
return importUploadedSkillArchive(cfg, filename, content)
}
return importUploadedMarkdownSkill(cfg, filename, content)
}
func importUploadedMarkdownSkill(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
skillName, err := normalizeImportedSkillName(filename, content)
if err != nil {
return nil, http.StatusBadRequest, err
}
normalizedContent := normalizeImportedSkillContent(content, skillName)
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
return nil, statusCodeForImportedSkillWriteError(err), err
}
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create skill directory: %v", err)
}
if err := fileutil.WriteFileAtomic(skillFile, normalizedContent, 0o644); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
}
return finalizeImportedSkill(cfg, skillDir, skillName, false)
}
func importUploadedSkillArchive(cfg *config.Config, filename string, content []byte) (*skillSupportItem, int, error) {
tmpDir, tempDirErr := os.MkdirTemp("", "picoclaw-skill-import-*")
if tempDirErr != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to create temp directory: %v", tempDirErr)
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "import.zip")
if writeErr := fileutil.WriteFileAtomic(archivePath, content, 0o600); writeErr != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to stage uploaded archive: %v", writeErr)
}
extractDir := filepath.Join(tmpDir, "extract")
if extractErr := utils.ExtractZipFile(archivePath, extractDir); extractErr != nil {
return nil, http.StatusBadRequest, fmt.Errorf("invalid ZIP archive: %w", extractErr)
}
skillRoot, err := findImportedSkillRoot(extractDir)
if err != nil {
return nil, http.StatusBadRequest, err
}
skillFile := filepath.Join(skillRoot, "SKILL.md")
skillContent, err := os.ReadFile(skillFile)
if err != nil {
return nil, http.StatusBadRequest, fmt.Errorf("failed to read SKILL.md from archive: %w", err)
}
directoryHint := ""
if filepath.Clean(skillRoot) != filepath.Clean(extractDir) {
directoryHint = filepath.Base(skillRoot)
}
skillName, err := normalizeImportedSkillNameWithHint(filename, directoryHint, skillContent)
if err != nil {
return nil, http.StatusBadRequest, err
}
workspace := cfg.WorkspacePath()
skillDir := filepath.Join(workspace, "skills", skillName)
if err := ensureWorkspaceSkillDoesNotExist(skillDir); err != nil {
return nil, statusCodeForImportedSkillWriteError(err), err
}
if err := copyImportedSkillTree(skillRoot, skillDir); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to save skill: %v", err)
}
normalizedContent := normalizeImportedSkillContent(skillContent, skillName)
if err := fileutil.WriteFileAtomic(filepath.Join(skillDir, "SKILL.md"), normalizedContent, 0o644); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to normalize skill: %v", err)
}
return finalizeImportedSkill(cfg, skillDir, skillName, true)
}
func isImportedSkillArchive(filename string, content []byte) bool {
if strings.EqualFold(filepath.Ext(filename), ".zip") {
return true
}
return len(content) >= 4 && bytes.HasPrefix(content, []byte("PK\x03\x04"))
}
func ensureWorkspaceSkillDoesNotExist(skillDir string) error {
if _, err := os.Stat(skillDir); err == nil {
return errImportedSkillExists
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect skill directory: %w", err)
}
return nil
}
func statusCodeForImportedSkillWriteError(err error) int {
if err == nil {
return http.StatusOK
}
if errors.Is(err, errImportedSkillExists) {
return http.StatusConflict
}
return http.StatusInternalServerError
}
func finalizeImportedSkill(
cfg *config.Config,
skillDir string,
skillName string,
requireValidatedSkill bool,
) (*skillSupportItem, int, error) {
if err := persistSkillOriginMeta(skillDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "manual",
InstalledAt: time.Now().UnixMilli(),
}); err != nil {
_ = os.RemoveAll(skillDir)
return nil, http.StatusInternalServerError, fmt.Errorf("Failed to persist skill metadata: %v", err)
}
if importedSkill := findWorkspaceSkillByDirectory(cfg, skillName); importedSkill != nil {
return importedSkill, http.StatusOK, nil
}
if requireValidatedSkill {
_ = os.RemoveAll(skillDir)
return nil, http.StatusBadRequest, fmt.Errorf("imported archive is not a valid skill")
}
return &skillSupportItem{
Name: skillName,
Path: filepath.Join(skillDir, "SKILL.md"),
Source: "workspace",
Description: "Imported skill",
OriginKind: "manual",
}, http.StatusOK, nil
}
func findImportedSkillRoot(extractDir string) (string, error) {
skillFiles := make([]string, 0, 1)
err := filepath.WalkDir(extractDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if d.Name() == "SKILL.md" {
skillFiles = append(skillFiles, path)
}
return nil
})
if err != nil {
return "", fmt.Errorf("failed to inspect ZIP archive: %w", err)
}
switch len(skillFiles) {
case 0:
return "", fmt.Errorf("ZIP archive must contain a SKILL.md file")
case 1:
return filepath.Dir(skillFiles[0]), nil
default:
return "", fmt.Errorf("ZIP archive must contain exactly one SKILL.md file")
}
}
func copyImportedSkillTree(srcDir, destDir string) error {
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
if relPath == "." {
return os.MkdirAll(destDir, 0o755)
}
destPath := filepath.Join(destDir, relPath)
info, err := d.Info()
if err != nil {
return err
}
if d.IsDir() {
return os.MkdirAll(destPath, 0o755)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("archive contains unsupported file %q", relPath)
}
return fileutil.CopyFile(path, destPath, info.Mode().Perm())
})
}
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
if len(matches) != 2 {
File diff suppressed because it is too large Load Diff
+82 -13
View File
@@ -5,22 +5,60 @@ export interface SkillSupportItem {
path: string
source: "workspace" | "global" | "builtin" | string
description: string
origin_kind: "builtin" | "third_party" | "manual" | string
registry_name?: string
registry_url?: string
installed_version?: string
installed_at?: number
}
export interface SkillDetailResponse extends SkillSupportItem {
content: string
}
export interface SkillRegistrySearchResult {
score: number
slug: string
display_name: string
summary: string
version: string
registry_name: string
url?: string
installed: boolean
installed_name?: string
}
interface SkillsResponse {
skills: SkillSupportItem[]
}
interface SkillActionResponse {
export interface SkillSearchResponse {
results: SkillRegistrySearchResult[]
limit: number
offset: number
next_offset?: number
has_more: boolean
}
type SkillActionResponse = Partial<SkillSupportItem> & {
status?: string
name?: string
path?: string
source?: string
description?: string
}
export interface InstallSkillRequest {
slug: string
registry: string
version?: string
force?: boolean
}
export interface InstallSkillResponse {
status: string
slug: string
registry: string
version: string
summary?: string
is_suspicious?: boolean
skill?: SkillSupportItem
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@@ -39,6 +77,29 @@ export async function getSkill(name: string): Promise<SkillDetailResponse> {
return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`)
}
export async function searchSkills(
query: string,
limit = 20,
offset = 0,
): Promise<SkillSearchResponse> {
const params = new URLSearchParams({
q: query,
limit: String(limit),
offset: String(offset),
})
return request<SkillSearchResponse>(`/api/skills/search?${params.toString()}`)
}
export async function installSkill(
input: InstallSkillRequest,
): Promise<InstallSkillResponse> {
return request<InstallSkillResponse>("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
})
}
export async function importSkill(file: File): Promise<SkillActionResponse> {
const formData = new FormData()
formData.set("file", file)
@@ -64,15 +125,23 @@ export async function deleteSkill(name: string): Promise<SkillActionResponse> {
async function extractErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as {
error?: string
errors?: string[]
const raw = await res.text()
if (raw.trim() === "") {
return `API error: ${res.status} ${res.statusText}`
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
return body.errors.join("; ")
}
if (typeof body.error === "string" && body.error.trim() !== "") {
return body.error
try {
const body = JSON.parse(raw) as {
error?: string
errors?: string[]
}
if (Array.isArray(body.errors) && body.errors.length > 0) {
return body.errors.join("; ")
}
if (typeof body.error === "string" && body.error.trim() !== "") {
return body.error
}
} catch {
return raw.trim()
}
} catch {
// ignore invalid body
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { ResultsPanel } from "./results-panel"
import { SearchPanel } from "./search-panel"
import { useHubMarketplace } from "./use-hub-marketplace"
export function HubPage() {
const { t } = useTranslation()
const hub = useHubMarketplace()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.hub")} />
<div
className="flex-1 overflow-auto px-6 py-6"
onScroll={hub.handleScroll}
>
<div className="mx-auto w-full max-w-[1000px] space-y-8">
<section className="animate-in fade-in mx-auto flex w-full flex-col items-center space-y-8 duration-300 md:duration-500">
<SearchPanel
marketQuery={hub.marketQuery}
canSearchMarketplace={hub.canSearchMarketplace}
isMarketSearchInitialLoading={hub.isMarketSearchInitialLoading}
unavailableToolMessages={hub.unavailableToolMessages}
onMarketQueryChange={hub.setMarketQuery}
onSearchSubmit={hub.handleSearchSubmit}
/>
<ResultsPanel
canSearchMarketplace={hub.canSearchMarketplace}
hasSubmittedQuery={hub.hasSubmittedQuery}
submittedQuery={hub.submittedMarketQuery}
marketResults={hub.marketResults}
marketSearchError={hub.marketSearchError}
isMarketSearchInitialLoading={hub.isMarketSearchInitialLoading}
isMarketSearchLoadingMore={hub.isMarketSearchLoadingMore}
canInstallFromMarketplace={hub.canInstallFromMarketplace}
getInstalledSkill={hub.getInstalledSkill}
isInstallPending={hub.isInstallPending}
onInstall={hub.handleInstall}
onViewInstalled={hub.handleViewInstalled}
/>
</section>
</div>
</div>
</div>
)
}
@@ -0,0 +1,132 @@
import {
IconCheck,
IconFileInfo,
IconLoader2,
IconPlus,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export function MarketSkillCard({
result,
canInstall,
installPending,
installedSkill,
onInstall,
onViewInstalled,
}: {
result: SkillRegistrySearchResult
canInstall: boolean
installPending: boolean
installedSkill: SkillSupportItem | null
onInstall: () => void
onViewInstalled: () => void
}) {
const { t } = useTranslation()
return (
<Card
className="group relative overflow-hidden border-border/40 bg-card/40 transition-all hover:border-border/80 hover:bg-card hover:shadow-md"
size="sm"
>
{result.installed && (
<div className="absolute inset-x-0 top-0 h-1 bg-emerald-500/20" />
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="mb-1 flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-semibold tracking-tight">
{result.display_name || result.slug}
</CardTitle>
<span className="inline-flex items-center rounded-md bg-muted/60 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-muted-foreground uppercase ring-1 ring-inset ring-border/50">
{result.registry_name}
</span>
{result.installed ? (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20">
{t("pages.agent.skills.marketplace_installed")}
</span>
) : null}
</div>
<div className="font-mono text-xs text-muted-foreground opacity-80">
{result.slug}
{result.version ? (
<span className="text-muted-foreground/60">
{" "}
· v{result.version}
</span>
) : null}
</div>
<CardDescription className="mt-2 line-clamp-2 text-sm leading-relaxed">
{result.summary}
</CardDescription>
{result.url ? (
<div className="pt-1">
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="inline-flex text-xs text-primary/80 transition-colors hover:text-primary hover:underline hover:underline-offset-4"
>
{result.url}
</a>
</div>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Button
size="sm"
variant={result.installed ? "secondary" : "default"}
className="shadow-sm transition-all"
disabled={!canInstall || result.installed || installPending}
onClick={onInstall}
>
{installPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : result.installed ? (
<IconCheck className="size-4" />
) : (
<IconPlus className="size-4" />
)}
{result.installed
? t("pages.agent.skills.marketplace_installed")
: t("pages.agent.skills.marketplace_install_action")}
</Button>
{result.installed && installedSkill ? (
<Button
variant="outline"
size="xs"
onClick={onViewInstalled}
className="w-full shadow-sm hover:bg-muted"
>
<IconFileInfo className="mr-1 size-3.5" />
{t("pages.agent.skills.marketplace_view_installed")}
</Button>
) : null}
</div>
</div>
</CardHeader>
{result.installed_name ? (
<CardContent className="pt-0 pb-4">
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
{t("pages.agent.skills.marketplace_installed_hint", {
name: result.installed_name,
})}
</div>
</CardContent>
) : null}
</Card>
)
}
@@ -0,0 +1,135 @@
import { IconLoader2, IconSearch, IconX } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import {
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { MarketSkillCard } from "./market-skill-card"
export function ResultsPanel({
canSearchMarketplace,
hasSubmittedQuery,
submittedQuery,
marketResults,
marketSearchError,
isMarketSearchInitialLoading,
isMarketSearchLoadingMore,
canInstallFromMarketplace,
getInstalledSkill,
isInstallPending,
onInstall,
onViewInstalled,
}: {
canSearchMarketplace: boolean
hasSubmittedQuery: boolean
submittedQuery: string
marketResults: SkillRegistrySearchResult[]
marketSearchError: unknown
isMarketSearchInitialLoading: boolean
isMarketSearchLoadingMore: boolean
canInstallFromMarketplace: boolean
getInstalledSkill: (installedName?: string) => SkillSupportItem | null
isInstallPending: (result: SkillRegistrySearchResult) => boolean
onInstall: (result: SkillRegistrySearchResult) => void
onViewInstalled: () => void
}) {
const { t } = useTranslation()
return (
<div className="mx-auto flex w-full max-w-[1000px] justify-center">
<div className="w-full">
{canSearchMarketplace && hasSubmittedQuery ? (
<div className="space-y-6">
<div className="rounded-xl border border-amber-200/80 bg-amber-50/70 px-4 py-3 text-sm text-amber-900">
<div className="font-semibold">
{t("pages.agent.skills.marketplace_notice_title")}
</div>
<div className="mt-1 leading-6">
{t("pages.agent.skills.marketplace_notice_body")}
</div>
</div>
{isMarketSearchInitialLoading ? (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl border border-dashed">
<IconLoader2 className="text-muted-foreground/60 size-6 animate-spin" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_loading_results")}
</span>
</div>
) : marketSearchError ? (
<div className="border-destructive/20 bg-destructive/5 rounded-xl border px-6 py-5">
<div className="text-destructive flex items-center gap-3">
<IconX className="size-5" />
<span className="text-sm font-medium">
{marketSearchError instanceof Error
? marketSearchError.message
: t("pages.agent.skills.marketplace_search_error")}
</span>
</div>
</div>
) : marketResults.length ? (
<div className="space-y-4">
<div className="border-border/40 flex items-center justify-between border-b pb-4">
<h3 className="text-foreground/85 text-base font-semibold">
{t("pages.agent.skills.marketplace_results_title", {
query: submittedQuery,
count: marketResults.length,
})}
</h3>
<span className="text-muted-foreground text-xs font-medium">
{t("pages.agent.skills.marketplace_results_hint")}
</span>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{marketResults.map((result) => (
<MarketSkillCard
key={`${result.registry_name}:${result.slug}`}
result={result}
canInstall={canInstallFromMarketplace}
installPending={isInstallPending(result)}
installedSkill={getInstalledSkill(result.installed_name)}
onInstall={() => onInstall(result)}
onViewInstalled={onViewInstalled}
/>
))}
</div>
{isMarketSearchLoadingMore ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 pt-2 text-sm">
<IconLoader2 className="size-4 animate-spin" />
<span>
{t("pages.agent.skills.marketplace_loading_more")}
</span>
</div>
) : null}
</div>
) : (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<IconSearch className="text-muted-foreground/50 size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_empty_results", {
query: submittedQuery,
})}
</span>
</div>
)}
</div>
) : !canSearchMarketplace ? (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_unavailable")}
</span>
</div>
) : (
<div className="border-border/40 bg-muted/10 flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed">
<IconSearch className="text-muted-foreground/50 size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("pages.agent.skills.marketplace_idle")}
</span>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,91 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { UnavailableToolMessage } from "./tool-support"
export function SearchPanel({
marketQuery,
canSearchMarketplace,
isMarketSearchInitialLoading,
unavailableToolMessages,
onMarketQueryChange,
onSearchSubmit,
}: {
marketQuery: string
canSearchMarketplace: boolean
isMarketSearchInitialLoading: boolean
unavailableToolMessages: UnavailableToolMessage[]
onMarketQueryChange: (value: string) => void
onSearchSubmit: () => void
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center space-y-6 py-8 text-center sm:py-12">
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">
{t("pages.agent.skills.marketplace_title", {
defaultValue: "Discover Skills",
})}
</h2>
<p className="text-muted-foreground max-w-[600px] text-base md:text-lg">
{t("pages.agent.skills.marketplace_description")}
</p>
</div>
<form
className="w-full max-w-2xl px-4 md:px-0"
onSubmit={(event) => {
event.preventDefault()
onSearchSubmit()
}}
>
<div className="group relative flex items-center justify-center">
<Input
value={marketQuery}
onChange={(event) => onMarketQueryChange(event.target.value)}
placeholder={t("pages.agent.skills.marketplace_search_placeholder")}
className="border-border/60 bg-background/50 hover:bg-background focus-visible:ring-primary/20 h-12 w-full rounded-full pr-20 pl-5 text-sm shadow-sm backdrop-blur-sm transition-all focus-visible:ring-2 md:min-w-[520px]"
disabled={!canSearchMarketplace}
/>
<Button
type="submit"
className="absolute top-1/2 right-1.5 h-9 -translate-y-1/2 rounded-full px-4 font-medium shadow-sm transition-all"
disabled={
!canSearchMarketplace ||
isMarketSearchInitialLoading ||
marketQuery.trim() === ""
}
>
{isMarketSearchInitialLoading ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<span>
{t("pages.agent.skills.marketplace_search_action", {
defaultValue: "Search",
})}
</span>
)}
</Button>
</div>
</form>
{unavailableToolMessages.length ? (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 pt-2">
{unavailableToolMessages.map((item) => (
<div
key={item.key}
className="rounded-xl border border-amber-200/80 bg-amber-50/70 px-4 py-3 text-left text-sm text-amber-900"
>
<div className="font-semibold">{item.label}</div>
<div className="mt-1 leading-6">{item.message}</div>
</div>
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,54 @@
import type { TFunction } from "i18next"
import type { ToolSupportItem } from "@/api/tools"
type MarketplaceTool = Pick<ToolSupportItem, "status" | "reason_code"> | undefined
export interface UnavailableToolMessage {
key: "search" | "install"
label: string
message: string
}
export function buildUnavailableToolMessages({
searchTool,
installTool,
t,
}: {
searchTool: MarketplaceTool
installTool: MarketplaceTool
t: TFunction
}): UnavailableToolMessage[] {
const searchMessage = getToolSupportMessage(searchTool, t)
const installMessage = getToolSupportMessage(installTool, t)
return [
searchMessage
? {
key: "search",
label: t("pages.agent.skills.marketplace_search_status"),
message: searchMessage,
}
: null,
installMessage
? {
key: "install",
label: t("pages.agent.skills.marketplace_install_status"),
message: installMessage,
}
: null,
].filter((item): item is UnavailableToolMessage => Boolean(item))
}
function getToolSupportMessage(
tool: MarketplaceTool,
t: TFunction,
): string | null {
if (!tool || tool.status === "enabled") {
return null
}
if (tool.reason_code) {
return `${t(`pages.agent.tools.reasons.${tool.reason_code}`)} ${t("pages.agent.skills.marketplace_status_enable_hint")}`
}
return t("pages.agent.skills.marketplace_status_disabled")
}
@@ -0,0 +1,211 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useEffect, useRef, useState, type UIEvent } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
getSkills,
installSkill,
searchSkills,
type SkillSearchResponse,
type SkillRegistrySearchResult,
type SkillSupportItem,
} from "@/api/skills"
import { getTools } from "@/api/tools"
import { buildUnavailableToolMessages } from "./tool-support"
const MARKET_SEARCH_LIMIT = 20
export function useHubMarketplace() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const isLoadMoreLockedRef = useRef(false)
const [marketQuery, setMarketQuery] = useState("")
const [submittedMarketQuery, setSubmittedMarketQuery] = useState("")
const { data: skillsData } = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const { data: toolsData } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const findSkillsTool = toolsData?.tools.find(
(tool) => tool.name === "find_skills",
)
const installSkillTool = toolsData?.tools.find(
(tool) => tool.name === "install_skill",
)
const canSearchMarketplace = findSkillsTool?.status === "enabled"
const canInstallFromMarketplace = installSkillTool?.status === "enabled"
const hasSubmittedQuery = submittedMarketQuery.trim() !== ""
const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery
const {
data: marketSearchData,
isPending: isMarketSearchPending,
isFetching: isMarketSearchFetching,
isFetchingNextPage,
error: marketSearchError,
hasNextPage,
fetchNextPage,
refetch: refetchMarketSearch,
} = useInfiniteQuery({
queryKey: ["skills-marketplace", submittedMarketQuery],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
searchSkills(
submittedMarketQuery,
MARKET_SEARCH_LIMIT,
Number(pageParam) || 0,
),
getNextPageParam: (lastPage: SkillSearchResponse) =>
lastPage.has_more ? lastPage.next_offset ?? undefined : undefined,
enabled: isMarketSearchActive,
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
})
const installMutation = useMutation({
mutationFn: installSkill,
onSuccess: (response) => {
toast.success(
t("pages.agent.skills.install_success", {
name: response.skill?.name ?? response.slug,
}),
)
void queryClient.invalidateQueries({ queryKey: ["skills"] })
void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.install_error"),
)
},
})
const allSkills = skillsData?.skills ?? []
const workspaceSkillsByName = new Map(
allSkills
.filter((skill) => skill.source === "workspace")
.map((skill) => [skill.name, skill] as const),
)
const marketResults =
marketSearchData?.pages.flatMap((page) => page.results) ?? []
const hasMoreMarketResults = hasNextPage ?? false
const isMarketSearchInitialLoading =
isMarketSearchActive &&
!marketSearchData &&
(isMarketSearchPending || isMarketSearchFetching)
const isMarketSearchLoadingMore =
isMarketSearchActive &&
Boolean(marketSearchData) &&
isFetchingNextPage
const installPendingKey =
installMutation.isPending && installMutation.variables
? `${installMutation.variables.registry}:${installMutation.variables.slug}`
: null
const unavailableToolMessages = buildUnavailableToolMessages({
searchTool: findSkillsTool,
installTool: installSkillTool,
t,
})
useEffect(() => {
if (!isFetchingNextPage) {
isLoadMoreLockedRef.current = false
}
}, [isFetchingNextPage])
const handleSearchSubmit = () => {
const nextQuery = marketQuery.trim()
if (!canSearchMarketplace || nextQuery === "") {
return
}
isLoadMoreLockedRef.current = false
if (nextQuery === submittedMarketQuery) {
void refetchMarketSearch()
return
}
setSubmittedMarketQuery(nextQuery)
}
const handleInstall = (result: SkillRegistrySearchResult) => {
installMutation.mutate({
slug: result.slug,
registry: result.registry_name,
version: result.version || undefined,
})
}
const handleViewInstalled = () => {
void navigate({ to: "/agent/skills" })
}
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (
!isMarketSearchActive ||
!hasMoreMarketResults ||
isFetchingNextPage ||
isLoadMoreLockedRef.current
) {
return
}
const node = event.currentTarget
const remaining = node.scrollHeight - node.scrollTop - node.clientHeight
if (remaining > 240) {
return
}
isLoadMoreLockedRef.current = true
void fetchNextPage()
}
const getInstalledSkill = (installedName?: string): SkillSupportItem | null => {
if (!installedName) {
return null
}
return workspaceSkillsByName.get(installedName) ?? null
}
const isInstallPending = (result: SkillRegistrySearchResult) =>
installPendingKey === `${result.registry_name}:${result.slug}`
return {
marketQuery,
submittedMarketQuery,
canSearchMarketplace,
canInstallFromMarketplace,
marketResults,
marketSearchError,
unavailableToolMessages,
hasSubmittedQuery,
isMarketSearchInitialLoading,
isMarketSearchLoadingMore,
setMarketQuery,
handleSearchSubmit,
handleInstall,
handleViewInstalled,
handleScroll,
getInstalledSkill,
isInstallPending,
}
}
@@ -0,0 +1,65 @@
import type { SkillSupportItem } from "@/api/skills"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { IconLoader2, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
interface DeleteDialogProps {
open: boolean
skillPendingDelete: SkillSupportItem | null
isDeletePending: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function DeleteDialog({
open,
skillPendingDelete,
isDeletePending,
onOpenChange,
onConfirm,
}: DeleteDialogProps) {
const { t } = useTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.agent.skills.delete_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.agent.skills.delete_description", {
name: skillPendingDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletePending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isDeletePending || !skillPendingDelete}
onClick={onConfirm}
>
{isDeletePending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconTrash className="size-4" />
)}
{t("pages.agent.skills.delete_confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,249 @@
import {
IconFileCode,
IconSparkles,
IconWorld,
IconX,
} from "@tabler/icons-react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { OriginBadge } from "./origin-badge"
import {
getOriginLabel,
getSkillOriginKind,
} from "./origin-utils"
import type { SkillDetailView } from "./types"
const DETAIL_VIEWS = [
"preview",
"raw",
"meta",
] as const satisfies SkillDetailView[]
interface DetailSheetProps {
open: boolean
selectedSkill: SkillSupportItem | null
selectedSkillDetail?: SkillDetailResponse
isLoading: boolean
error: unknown
detailView: SkillDetailView
onDetailViewChange: (view: SkillDetailView) => void
onOpenChange: (open: boolean) => void
}
export function DetailSheet({
open,
selectedSkill,
selectedSkillDetail,
isLoading,
error,
detailView,
onDetailViewChange,
onOpenChange,
}: DetailSheetProps) {
const { t } = useTranslation()
const activeSkillDetail = selectedSkillDetail ?? selectedSkill
const activeSkillOrigin = activeSkillDetail
? getSkillOriginKind(activeSkillDetail)
: null
const detailLineCount = selectedSkillDetail
? selectedSkillDetail.content.split("\n").length
: 0
const detailCharacterCount = selectedSkillDetail?.content.length ?? 0
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 shadow-2xl data-[side=right]:!w-full data-[side=right]:sm:!w-[720px] data-[side=right]:sm:!max-w-[720px]"
>
<SheetHeader className="bg-muted/10 border-b px-6 py-6">
<div className="flex items-center gap-3">
<div className="bg-primary/10 string-1 ring-primary/20 text-primary flex size-10 items-center justify-center rounded-xl">
{activeSkillDetail?.origin_kind === "builtin" ? (
<IconSparkles className="size-5" />
) : activeSkillDetail?.registry_name ? (
<IconWorld className="size-5" />
) : (
<IconFileCode className="size-5" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1 text-left">
<SheetTitle className="truncate text-xl font-bold tracking-tight">
{activeSkillDetail?.name || t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription className="line-clamp-2">
{activeSkillDetail?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-x-hidden overflow-y-scroll px-6 py-6">
{isLoading ? (
<div className="space-y-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-[400px] w-full rounded-xl" />
</div>
) : error ? (
<div className="text-destructive border-destructive/20 bg-destructive/5 flex h-40 flex-col items-center justify-center gap-3 rounded-xl border">
<IconX className="size-6 opacity-80" />
<span className="text-sm font-medium">
{t("pages.agent.skills.load_detail_error")}
</span>
</div>
) : selectedSkillDetail ? (
<div className="space-y-6">
{activeSkillOrigin === "third_party" ? (
<div className="border-border/40 bg-card/40 space-y-4 rounded-xl border p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2 px-1">
<OriginBadge
origin={activeSkillOrigin}
label={getOriginLabel(activeSkillOrigin, t)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{selectedSkillDetail.registry_name ? (
<MetadataItem
label={t("pages.agent.skills.metadata.registry")}
value={selectedSkillDetail.registry_name}
/>
) : null}
{selectedSkillDetail.installed_version ? (
<MetadataItem
label={t("pages.agent.skills.metadata.version")}
value={selectedSkillDetail.installed_version}
/>
) : null}
{selectedSkillDetail.registry_url ? (
<MetadataItem
label={t("pages.agent.skills.metadata.url")}
value={
<a
href={selectedSkillDetail.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary hover:text-primary/80 inline break-all underline-offset-4 hover:underline"
>
{selectedSkillDetail.registry_url}
</a>
}
mono
/>
) : null}
</div>
</div>
) : null}
<div className="border-border/70 bg-muted/20 inline-flex rounded-lg border p-1 shadow-sm">
{DETAIL_VIEWS.map((view) => (
<button
key={view}
type="button"
className={cn(
"rounded-md px-4 py-1.5 text-xs font-medium transition-all duration-200",
detailView === view
? "bg-background text-foreground ring-border/30 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
onClick={() => onDetailViewChange(view)}
>
{t(`pages.agent.skills.detail_tabs.${view}`)}
</button>
))}
</div>
{detailView === "preview" ? (
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-950/90 prose-pre:shadow-sm prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
) : null}
{detailView === "raw" ? (
<div className="border-border/50 overflow-x-auto rounded-xl border bg-zinc-950 p-5 shadow-sm">
<pre className="font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap text-zinc-100/90">
<code>{selectedSkillDetail.content}</code>
</pre>
</div>
) : null}
{detailView === "meta" ? (
<div className="grid gap-4 sm:grid-cols-2">
<MetadataItem
label={t("pages.agent.skills.metadata.name")}
value={selectedSkillDetail.name}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.description")}
value={
selectedSkillDetail.description ||
t("pages.agent.skills.no_description")
}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.lines")}
value={String(detailLineCount)}
/>
<MetadataItem
label={t("pages.agent.skills.metadata.characters")}
value={String(detailCharacterCount)}
/>
</div>
) : null}
</div>
) : null}
</div>
</SheetContent>
</Sheet>
)
}
function MetadataItem({
label,
value,
mono = false,
}: {
label: string
value: ReactNode
mono?: boolean
}) {
return (
<div className="border-border/70 bg-muted/20 rounded-xl border px-4 py-3">
<div className="text-muted-foreground text-[11px] font-semibold tracking-[0.18em] uppercase">
{label}
</div>
<div
className={cn(
"text-foreground mt-2 text-sm leading-6 break-all",
mono && "font-mono text-xs",
)}
>
{value}
</div>
</div>
)
}
@@ -0,0 +1,136 @@
import {
IconLayoutGrid,
IconLayoutList,
IconSearch,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { getOriginLabel } from "./origin-utils"
import type { SkillLayoutMode, SkillSortOption } from "./types"
interface FilterBarProps {
searchQuery: string
sourceFilter: string
availableOrigins: string[]
sortOrder: SkillSortOption
layoutMode: SkillLayoutMode
onSearchQueryChange: (value: string) => void
onSourceFilterChange: (value: string) => void
onSortOrderChange: (value: SkillSortOption) => void
onLayoutModeChange: (value: SkillLayoutMode) => void
}
export function FilterBar({
searchQuery,
sourceFilter,
availableOrigins,
sortOrder,
layoutMode,
onSearchQueryChange,
onSourceFilterChange,
onSortOrderChange,
onLayoutModeChange,
}: FilterBarProps) {
const { t } = useTranslation()
return (
<div className="border-border/40 bg-muted/20 flex flex-wrap items-center gap-3 rounded-xl border p-2 shadow-sm">
<div className="relative min-w-[200px] flex-1">
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={t("pages.agent.skills.search_placeholder")}
className="hover:bg-background/50 focus-visible:bg-background h-9 border-transparent bg-transparent pl-9 shadow-none focus-visible:ring-1"
/>
</div>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<Select value={sourceFilter} onValueChange={onSourceFilterChange}>
<SelectTrigger className="hover:bg-background/50 focus:bg-background h-9 w-[140px] border-transparent bg-transparent shadow-none hover:ring-1 focus:ring-1">
<SelectValue placeholder={t("pages.agent.skills.source_label")} />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">
{t("pages.agent.skills.origin.all")}
</SelectItem>
{availableOrigins.map((origin) => (
<SelectItem key={origin} value={origin}>
{getOriginLabel(origin, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<Select
value={sortOrder}
onValueChange={(value) => onSortOrderChange(value as SkillSortOption)}
>
<SelectTrigger className="hover:bg-background/50 focus:bg-background h-9 w-[160px] border-transparent bg-transparent shadow-none hover:ring-1 focus:ring-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{t("pages.agent.skills.sort_label", {
defaultValue: "Sort by",
})}
:
</span>
<SelectValue />
</div>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="name-asc">
{t("pages.agent.skills.sort.name_asc")}
</SelectItem>
<SelectItem value="name-desc">
{t("pages.agent.skills.sort.name_desc")}
</SelectItem>
<SelectItem value="source">
{t("pages.agent.skills.sort.source")}
</SelectItem>
</SelectContent>
</Select>
<div className="bg-border/60 hidden h-6 w-px sm:block" />
<div className="bg-background/50 ring-border/20 inline-flex items-center rounded-lg p-0.5 shadow-sm ring-1">
<button
type="button"
className={cn(
"rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
layoutMode === "grouped"
? "bg-background text-foreground ring-border/30 shadow-sm ring-1"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => onLayoutModeChange("grouped")}
>
<IconLayoutList className="size-4" />
</button>
<button
type="button"
className={cn(
"rounded-md px-2.5 py-1.5 text-xs font-medium transition-all",
layoutMode === "grid"
? "bg-background text-foreground ring-border/30 shadow-sm ring-1"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => onLayoutModeChange("grid")}
>
<IconLayoutGrid className="size-4" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,160 @@
import { IconLoader2, IconUpload, IconX } from "@tabler/icons-react"
import type { DragEvent } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
interface ImportDialogProps {
open: boolean
isImportPending: boolean
isDragActive: boolean
onOpenChange: (open: boolean) => void
onImportClick: () => void
onDragEnter: (event: DragEvent<HTMLDivElement>) => void
onDragLeave: (event: DragEvent<HTMLDivElement>) => void
onDrop: (event: DragEvent<HTMLDivElement>) => void
}
export function ImportDialog({
open,
isImportPending,
isDragActive,
onOpenChange,
onImportClick,
onDragEnter,
onDragLeave,
onDrop,
}: ImportDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isImportPending) {
onOpenChange(nextOpen)
}
}}
>
<DialogContent
showCloseButton={false}
className="border-border/40 bg-card/95 max-w-[420px] gap-6 p-6 text-center shadow-lg backdrop-blur-sm focus:outline-none sm:rounded-2xl"
>
<div className="relative space-y-1 px-8">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground absolute top-0 right-0"
onClick={() => onOpenChange(false)}
disabled={isImportPending}
aria-label={t("common.cancel")}
title={t("common.cancel")}
>
<IconX className="size-4" />
</Button>
<DialogHeader className="space-y-1 text-center">
<DialogTitle className="text-lg font-semibold tracking-tight">
{t("pages.agent.skills.dropzone_title")}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm">
{t("pages.agent.skills.dropzone_description")}
</DialogDescription>
</DialogHeader>
</div>
<SkillImportPanel
isDragActive={isDragActive}
isImportPending={isImportPending}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onImportClick={onImportClick}
/>
</DialogContent>
</Dialog>
)
}
function SkillImportPanel({
isDragActive,
isImportPending,
onDragEnter,
onDragLeave,
onDrop,
onImportClick,
}: {
isDragActive: boolean
isImportPending: boolean
onDragEnter: (event: DragEvent<HTMLDivElement>) => void
onDragLeave: (event: DragEvent<HTMLDivElement>) => void
onDrop: (event: DragEvent<HTMLDivElement>) => void
onImportClick: () => void
}) {
const { t } = useTranslation()
return (
<div className="space-y-4">
<div
className={cn(
"flex min-h-48 cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed px-4 py-6 text-center transition-all duration-300",
isDragActive
? "border-primary bg-primary/10 scale-[1.02]"
: "border-border/60 bg-muted/30 hover:bg-muted/50 hover:border-primary/50",
isImportPending && "pointer-events-none opacity-50",
)}
onClick={() => {
if (!isImportPending) {
onImportClick()
}
}}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
<div
className={cn(
"mb-2 rounded-full p-3 transition-colors duration-300",
isDragActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-background text-muted-foreground ring-border/50 shadow-sm ring-1",
)}
>
<IconUpload className="size-6" />
</div>
<div className="space-y-1">
<div className="text-foreground text-sm font-semibold tracking-tight">
{isDragActive
? t("pages.agent.skills.dropzone_active")
: t("pages.agent.skills.dropzone_label")}
</div>
<p className="text-muted-foreground mx-auto hidden max-w-[270px] text-xs leading-relaxed sm:block">
{isDragActive
? t("pages.agent.skills.dropzone_release")
: t("pages.agent.skills.import_constraints")}
</p>
</div>
<Button
variant="secondary"
size="sm"
className="pointer-events-none mt-2 h-8 shadow-sm"
disabled={isImportPending}
>
{isImportPending ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : null}
{t("pages.agent.skills.import")}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import {
IconFileCode,
IconFolder,
IconSparkles,
IconWorld,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { getOriginBadgeClasses } from "./origin-utils"
export function OriginBadge({
origin,
label,
}: {
origin: string
label: string
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-semibold",
getOriginBadgeClasses(origin),
)}
>
<OriginIcon origin={origin} />
{label}
</span>
)
}
export function OriginIcon({ origin }: { origin: string }) {
if (origin === "builtin") {
return <IconSparkles className="size-3.5" />
}
if (origin === "third_party") {
return <IconWorld className="size-3.5" />
}
if (origin === "manual") {
return <IconFolder className="size-3.5" />
}
if (origin === "all") {
return <IconFileCode className="size-4" />
}
return <IconFileCode className="size-3.5" />
}
@@ -0,0 +1,86 @@
import type { TFunction } from "i18next"
import type { SkillSupportItem } from "@/api/skills"
import type { SkillSortOption } from "./types"
const KNOWN_ORIGIN_ORDER = ["builtin", "third_party", "manual"]
export function compareSkills(
left: SkillSupportItem,
right: SkillSupportItem,
sortOrder: SkillSortOption,
) {
if (sortOrder === "source") {
const sourceDelta = compareOriginOrder(
getSkillOriginKind(left),
getSkillOriginKind(right),
)
if (sourceDelta !== 0) return sourceDelta
return left.name.localeCompare(right.name)
}
if (sortOrder === "name-desc") {
return right.name.localeCompare(left.name)
}
return left.name.localeCompare(right.name)
}
export function sortOrigins(origins: string[]) {
return [...origins].sort(compareOriginOrder)
}
export function getSkillOriginKind(skill: SkillSupportItem) {
const origin = skill.origin_kind || skill.source
return origin === "global" ? "builtin" : origin
}
export function getOriginLabel(origin: string, t: TFunction) {
if (origin === "builtin" || origin === "third_party" || origin === "manual") {
return t(`pages.agent.skills.origin.${origin}`)
}
if (origin === "all") {
return t("pages.agent.skills.origin.all")
}
return origin
}
export function getOriginAccentClasses(origin: string) {
if (origin === "manual") {
return "bg-emerald-100 text-emerald-700"
}
if (origin === "third_party") {
return "bg-sky-100 text-sky-700"
}
if (origin === "builtin") {
return "bg-amber-100 text-amber-700"
}
return "bg-muted text-muted-foreground"
}
export function getOriginBadgeClasses(origin: string) {
if (origin === "manual") {
return "bg-emerald-100 text-emerald-700"
}
if (origin === "third_party") {
return "bg-sky-100 text-sky-700"
}
if (origin === "builtin") {
return "bg-amber-100 text-amber-700"
}
return "bg-muted text-muted-foreground"
}
function compareOriginOrder(left: string, right: string) {
const leftIndex = KNOWN_ORIGIN_ORDER.indexOf(left)
const rightIndex = KNOWN_ORIGIN_ORDER.indexOf(right)
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1
if (rightIndex === -1) return -1
return leftIndex - rightIndex
}
return left.localeCompare(right)
}
@@ -0,0 +1,27 @@
import { Skeleton } from "@/components/ui/skeleton"
export function PageSkeleton() {
return (
<div className="mt-4 space-y-8">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map((index) => (
<Skeleton
key={index}
className="border-border/40 h-24 w-full rounded-xl border"
/>
))}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48" />
</div>
<Skeleton className="h-14 w-full rounded-xl" />
<div className="grid gap-4 pt-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((index) => (
<Skeleton key={index} className="h-36 w-full rounded-xl" />
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,84 @@
import { IconFileInfo, IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
interface SkillCardProps {
skill: SkillSupportItem
onView: () => void
onDelete: () => void
}
export function SkillCard({ skill, onView, onDelete }: SkillCardProps) {
const { t } = useTranslation()
return (
<Card
className="group border-border/40 bg-card/40 hover:bg-card hover:border-border/80 relative overflow-hidden transition-all hover:shadow-md"
size="sm"
>
<div className="via-primary/10 absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-semibold tracking-tight">
{skill.name}
</CardTitle>
{skill.registry_name ? (
<span className="bg-muted/60 text-muted-foreground ring-border/50 inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold tracking-wider uppercase ring-1 ring-inset">
{skill.registry_name}
</span>
) : null}
</div>
<CardDescription className="line-clamp-2 text-sm leading-relaxed">
{skill.description || t("pages.agent.skills.no_description")}
</CardDescription>
</div>
<div className="flex items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={onView}
title={t("pages.agent.skills.view")}
>
<IconFileInfo className="size-4" />
</Button>
{skill.source === "workspace" ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title={t("pages.agent.skills.delete")}
>
<IconTrash className="size-4" />
</Button>
) : null}
</div>
</div>
</CardHeader>
<CardContent>
{skill.registry_url ? (
<a
href={skill.registry_url}
target="_blank"
rel="noreferrer"
className="text-primary/80 hover:text-primary inline-flex items-center text-xs transition-colors hover:underline hover:underline-offset-4"
>
{skill.registry_url}
</a>
) : null}
</CardContent>
</Card>
)
}
@@ -0,0 +1,86 @@
import { IconSearch } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { SkillSupportItem } from "@/api/skills"
import { OriginBadge } from "./origin-badge"
import { getOriginLabel } from "./origin-utils"
import { SkillCard } from "./skill-card"
import type { SkillGroupSection, SkillLayoutMode } from "./types"
interface SkillsListProps {
sortedSkills: SkillSupportItem[]
groupedSkills: SkillGroupSection[]
layoutMode: SkillLayoutMode
sourceFilter: string
hasActiveFilters: boolean
onViewSkill: (skill: SkillSupportItem) => void
onDeleteSkill: (skill: SkillSupportItem) => void
}
export function SkillsList({
sortedSkills,
groupedSkills,
layoutMode,
sourceFilter,
hasActiveFilters,
onViewSkill,
onDeleteSkill,
}: SkillsListProps) {
const { t } = useTranslation()
if (!sortedSkills.length) {
return (
<div className="border-border/40 bg-muted/5 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-center shadow-sm">
<div className="bg-muted mb-2 rounded-full p-4">
<IconSearch className="text-muted-foreground size-6" />
</div>
<h3 className="text-lg font-semibold tracking-tight">
{hasActiveFilters
? t("pages.agent.skills.no_results")
: t("pages.agent.skills.empty")}
</h3>
</div>
)
}
if (layoutMode === "grouped" && sourceFilter === "all") {
return (
<div className="space-y-6">
{groupedSkills.map((section) => (
<div key={section.origin} className="space-y-3">
<div className="flex items-center justify-between gap-3">
<OriginBadge
origin={section.origin}
label={getOriginLabel(section.origin, t)}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{section.skills.map((skill) => (
<SkillCard
key={`${skill.source}:${skill.name}`}
skill={skill}
onView={() => onViewSkill(skill)}
onDelete={() => onDeleteSkill(skill)}
/>
))}
</div>
</div>
))}
</div>
)
}
return (
<div className="grid gap-4 lg:grid-cols-2">
{sortedSkills.map((skill) => (
<SkillCard
key={`${skill.source}:${skill.name}`}
skill={skill}
onView={() => onViewSkill(skill)}
onDelete={() => onDeleteSkill(skill)}
/>
))}
</div>
)
}
@@ -0,0 +1,160 @@
import { IconLoader2, IconPlus } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { DeleteDialog } from "./delete-dialog"
import { DetailSheet } from "./detail-sheet"
import { FilterBar } from "./filter-bar"
import { ImportDialog } from "./import-dialog"
import { PageSkeleton } from "./page-skeleton"
import { SkillsList } from "./skills-list"
import { Stats } from "./stats"
import { useSkillsPage } from "./use-skills-page"
export function SkillsPage() {
const { t } = useTranslation()
const {
searchQuery,
sourceFilter,
sortOrder,
layoutMode,
detailView,
isDragActive,
isImportDialogOpen,
selectedSkill,
skillPendingDelete,
availableOrigins,
groupedSkills,
stats,
sortedSkills,
hasActiveFilters,
importInputRef,
selectedSkillDetail,
skillsError,
skillDetailError,
isLoading,
isSkillDetailLoading,
isImportPending,
isDeletePending,
setSearchQuery,
setSourceFilter,
setSortOrder,
setLayoutMode,
setDetailView,
openImportDialog,
handleViewSkill,
handleRequestDelete,
handleConfirmDelete,
handleImportClick,
handleImportFileChange,
handleDropZoneDragEnter,
handleDropZoneDragLeave,
handleDropZoneDrop,
handleDetailSheetOpenChange,
handleImportDialogOpenChange,
handleDeleteDialogOpenChange,
} = useSkillsPage()
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.skills")}
children={
<>
<input
ref={importInputRef}
type="file"
accept=".md,.zip,text/markdown,text/plain,application/zip,application/x-zip-compressed"
className="hidden"
onChange={handleImportFileChange}
/>
<Button
variant="outline"
onClick={openImportDialog}
disabled={isImportPending}
>
{isImportPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconPlus className="size-4" />
)}
{t("pages.agent.skills.import")}
</Button>
</>
}
/>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="w-full max-w-6xl space-y-8">
{isLoading ? (
<PageSkeleton />
) : skillsError ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="animate-in fade-in space-y-3 duration-300 md:duration-500">
<Stats stats={stats} />
<div className="flex flex-col gap-4 py-3">
<FilterBar
searchQuery={searchQuery}
sourceFilter={sourceFilter}
availableOrigins={availableOrigins}
sortOrder={sortOrder}
layoutMode={layoutMode}
onSearchQueryChange={setSearchQuery}
onSourceFilterChange={setSourceFilter}
onSortOrderChange={setSortOrder}
onLayoutModeChange={setLayoutMode}
/>
</div>
<SkillsList
sortedSkills={sortedSkills}
groupedSkills={groupedSkills}
layoutMode={layoutMode}
sourceFilter={sourceFilter}
hasActiveFilters={hasActiveFilters}
onViewSkill={handleViewSkill}
onDeleteSkill={handleRequestDelete}
/>
</section>
)}
</div>
</div>
<DetailSheet
open={selectedSkill !== null}
selectedSkill={selectedSkill}
selectedSkillDetail={selectedSkillDetail}
isLoading={isSkillDetailLoading}
error={skillDetailError}
detailView={detailView}
onDetailViewChange={setDetailView}
onOpenChange={handleDetailSheetOpenChange}
/>
<ImportDialog
open={isImportDialogOpen}
isImportPending={isImportPending}
isDragActive={isDragActive}
onOpenChange={handleImportDialogOpenChange}
onImportClick={handleImportClick}
onDragEnter={handleDropZoneDragEnter}
onDragLeave={handleDropZoneDragLeave}
onDrop={handleDropZoneDrop}
/>
<DeleteDialog
open={skillPendingDelete !== null}
skillPendingDelete={skillPendingDelete}
isDeletePending={isDeletePending}
onOpenChange={handleDeleteDialogOpenChange}
onConfirm={handleConfirmDelete}
/>
</div>
)
}
@@ -0,0 +1,39 @@
import { Card, CardContent } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { OriginIcon } from "./origin-badge"
import { getOriginAccentClasses } from "./origin-utils"
import type { SkillStatItem } from "./types"
export function Stats({ stats }: { stats: SkillStatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<Card
key={stat.key}
size="sm"
className="border-border/40 bg-card/40 hover:bg-card gap-3 shadow-sm transition-all hover:shadow-md"
>
<CardContent className="flex items-center justify-between pt-4">
<div className="space-y-1">
<div className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{stat.label}
</div>
<div className="text-2xl font-bold tracking-tight">
{stat.count}
</div>
</div>
<div
className={cn(
"rounded-xl p-2.5 shadow-sm ring-1 ring-white/10 ring-inset",
getOriginAccentClasses(stat.origin),
)}
>
<OriginIcon origin={stat.origin} />
</div>
</CardContent>
</Card>
))}
</div>
)
}
@@ -0,0 +1,17 @@
import type { SkillSupportItem } from "@/api/skills"
export type SkillSortOption = "name-asc" | "name-desc" | "source"
export type SkillLayoutMode = "grouped" | "grid"
export type SkillDetailView = "preview" | "raw" | "meta"
export interface SkillGroupSection {
origin: string
skills: SkillSupportItem[]
}
export interface SkillStatItem {
key: string
origin: string
label: string
count: number
}
@@ -0,0 +1,336 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
type ChangeEvent,
type DragEvent,
startTransition,
useDeferredValue,
useMemo,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
type SkillSupportItem,
deleteSkill,
getSkill,
getSkills,
importSkill,
} from "@/api/skills"
import {
compareSkills,
getOriginLabel,
getSkillOriginKind,
sortOrigins,
} from "./origin-utils"
import type {
SkillDetailView,
SkillGroupSection,
SkillLayoutMode,
SkillSortOption,
SkillStatItem,
} from "./types"
const MAX_IMPORT_FILE_SIZE = 1 << 20
export function useSkillsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const importInputRef = useRef<HTMLInputElement | null>(null)
const dragDepthRef = useRef(0)
const [searchQuery, setSearchQuery] = useState("")
const deferredSearchQuery = useDeferredValue(searchQuery)
const [sourceFilter, setSourceFilter] = useState("all")
const [sortOrder, setSortOrder] = useState<SkillSortOption>("name-asc")
const [layoutMode, setLayoutMode] = useState<SkillLayoutMode>("grouped")
const [detailView, setDetailView] = useState<SkillDetailView>("preview")
const [isDragActive, setIsDragActive] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
null,
)
const [skillPendingDelete, setSkillPendingDelete] =
useState<SkillSupportItem | null>(null)
const skillsQuery = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const skillDetailQuery = useQuery({
queryKey: ["skills", selectedSkill?.name],
queryFn: () => getSkill(selectedSkill!.name),
enabled: selectedSkill !== null,
})
const importMutation = useMutation({
mutationFn: async (file: File) => importSkill(file),
onSuccess: (importedSkill) => {
toast.success(t("pages.agent.skills.import_success"))
startTransition(() => {
setIsImportDialogOpen(false)
setDetailView("preview")
if (importedSkill.name) {
setSelectedSkill({
name: importedSkill.name,
path: importedSkill.path ?? "",
source: importedSkill.source ?? "workspace",
description: importedSkill.description ?? "",
origin_kind: importedSkill.origin_kind ?? "manual",
registry_name: importedSkill.registry_name,
registry_url: importedSkill.registry_url,
installed_version: importedSkill.installed_version,
installed_at: importedSkill.installed_at,
})
}
})
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.import_error"),
)
},
})
const deleteMutation = useMutation({
mutationFn: async (name: string) => deleteSkill(name),
onSuccess: (_, deletedName) => {
toast.success(t("pages.agent.skills.delete_success"))
setSkillPendingDelete(null)
if (
selectedSkill?.name === deletedName &&
selectedSkill.source === "workspace"
) {
setSelectedSkill(null)
}
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.delete_error"),
)
},
})
const allSkills = useMemo(
() => skillsQuery.data?.skills ?? [],
[skillsQuery.data?.skills],
)
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const availableOrigins = useMemo(
() =>
sortOrigins([
...new Set(allSkills.map((skill) => getSkillOriginKind(skill))),
]),
[allSkills],
)
const filteredSkills = useMemo(() => {
return allSkills.filter((skill) => {
const matchesSource =
sourceFilter === "all"
? true
: getSkillOriginKind(skill) === sourceFilter
if (!matchesSource) return false
if (normalizedSearchQuery === "") return true
const searchTarget =
`${skill.name} ${skill.description} ${skill.registry_name ?? ""}`.toLowerCase()
return searchTarget.includes(normalizedSearchQuery)
})
}, [allSkills, normalizedSearchQuery, sourceFilter])
const sortedSkills = useMemo(
() => [...filteredSkills].sort((left, right) => compareSkills(left, right, sortOrder)),
[filteredSkills, sortOrder],
)
const groupedSkills = useMemo<SkillGroupSection[]>(
() =>
availableOrigins
.map((origin) => ({
origin,
skills: sortedSkills.filter(
(skill) => getSkillOriginKind(skill) === origin,
),
}))
.filter((section) => section.skills.length > 0),
[availableOrigins, sortedSkills],
)
const stats = useMemo<SkillStatItem[]>(
() => [
{
key: "all",
origin: "all",
label: t("pages.agent.skills.summary.total"),
count: allSkills.length,
},
...availableOrigins.map((origin) => ({
key: origin,
origin,
label: getOriginLabel(origin, t),
count: allSkills.filter((skill) => getSkillOriginKind(skill) === origin)
.length,
})),
],
[allSkills, availableOrigins, t],
)
const hasActiveFilters =
normalizedSearchQuery !== "" || sourceFilter !== "all"
const handleImportClick = () => {
importInputRef.current?.click()
}
const handleViewSkill = (skill: SkillSupportItem) => {
setDetailView("preview")
setSelectedSkill(skill)
}
const handleRequestDelete = (skill: SkillSupportItem) => {
setSkillPendingDelete(skill)
}
const handleConfirmDelete = () => {
if (skillPendingDelete) {
deleteMutation.mutate(skillPendingDelete.name)
}
}
const handleDetailSheetOpenChange = (open: boolean) => {
if (!open) {
setSelectedSkill(null)
}
}
const handleImportDialogOpenChange = (open: boolean) => {
if (!importMutation.isPending) {
setIsImportDialogOpen(open)
}
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open) {
setSkillPendingDelete(null)
}
}
const validateImportFile = (file: File) => {
const fileName = file.name.toLowerCase()
const isMarkdownFile =
fileName.endsWith(".md") ||
file.type === "text/markdown" ||
file.type === "text/plain" ||
file.type === ""
const isZipFile =
fileName.endsWith(".zip") ||
file.type === "application/zip" ||
file.type === "application/x-zip-compressed"
if (!isMarkdownFile && !isZipFile) {
return t("pages.agent.skills.import_invalid_type")
}
if (file.size > MAX_IMPORT_FILE_SIZE) {
return t("pages.agent.skills.import_invalid_size")
}
return null
}
const handleImportFile = (file: File) => {
const validationMessage = validateImportFile(file)
if (validationMessage) {
toast.error(validationMessage)
return
}
importMutation.mutate(file)
}
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
handleImportFile(file)
event.target.value = ""
}
const resetDragState = () => {
dragDepthRef.current = 0
setIsDragActive(false)
}
const handleDropZoneDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current += 1
setIsDragActive(true)
}
const handleDropZoneDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragActive(false)
}
}
const handleDropZoneDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
const file = event.dataTransfer.files?.[0]
resetDragState()
if (!file) return
handleImportFile(file)
}
return {
searchQuery,
sourceFilter,
sortOrder,
layoutMode,
detailView,
isDragActive,
isImportDialogOpen,
selectedSkill,
skillPendingDelete,
availableOrigins,
groupedSkills,
stats,
sortedSkills,
hasActiveFilters,
importInputRef,
selectedSkillDetail: skillDetailQuery.data,
skillsError: skillsQuery.error,
skillDetailError: skillDetailQuery.error,
isLoading: skillsQuery.isLoading,
isSkillDetailLoading: skillDetailQuery.isLoading,
isImportPending: importMutation.isPending,
isDeletePending: deleteMutation.isPending,
setSearchQuery,
setSourceFilter,
setSortOrder,
setLayoutMode,
setDetailView,
openImportDialog: () => setIsImportDialogOpen(true),
handleViewSkill,
handleRequestDelete,
handleConfirmDelete,
handleImportClick,
handleImportFileChange,
handleDropZoneDragEnter,
handleDropZoneDragLeave,
handleDropZoneDrop,
handleDetailSheetOpenChange,
handleImportDialogOpenChange,
handleDeleteDialogOpenChange,
}
}
@@ -0,0 +1,288 @@
import { IconSearch } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
// Filter and group tools
const { groupedTools, totalFilteredCount } = useMemo(() => {
if (!data) return { groupedTools: [], totalFilteredCount: 0 }
let count = 0
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
// Apply status filter
if (statusFilter !== "all" && item.status !== statusFilter) continue
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
const matchesName = item.name.toLowerCase().includes(query)
const matchesDesc = (item.description || "")
.toLowerCase()
.includes(query)
if (!matchesName && !matchesDesc) continue
}
count++
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return {
groupedTools: Array.from(buckets.entries()),
totalFilteredCount: count,
}
}, [data, searchQuery, statusFilter])
return (
<div className="bg-background flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto w-full max-w-6xl space-y-8">
{/* Header & Description */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-end">
{/* Filters Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<IconSearch className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
type="text"
placeholder={t("pages.agent.tools.search_placeholder")}
className="w-full pl-9 sm:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue
placeholder={t("pages.agent.tools.filter.all")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("pages.agent.tools.filter.all")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Content Area */}
{error ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardContent className="py-10 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error")}
</p>
</CardContent>
</Card>
) : isLoading ? (
// Skeleton Loading State
<div className="space-y-8">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-4">
<Skeleton className="h-5 w-32" />
<div className="grid gap-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((itemIndex) => (
<Card
key={itemIndex}
className="border-border/60 shadow-none"
>
<CardHeader className="pb-3">
<Skeleton className="mb-2 h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="mt-2 h-8 w-full rounded-md" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
) : totalFilteredCount === 0 ? (
// Empty State
<Card className="bg-muted/30 cursor-default border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center text-sm">
<div className="bg-muted mb-4 rounded-full p-4">
<IconSearch className="text-muted-foreground size-8" />
</div>
<h3 className="mb-1 text-lg font-medium">
{data?.tools.length === 0
? t("pages.agent.tools.empty")
: t("pages.agent.tools.no_results")}
</h3>
{data?.tools.length !== 0 && (
<p className="text-muted-foreground">
Try adjusting your search criteria or status filters.
</p>
)}
</CardContent>
</Card>
) : (
// Tool Categories list
<div className="space-y-8">
{groupedTools.map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-foreground text-sm font-semibold tracking-wide uppercase">
{t(`pages.agent.tools.categories.${category}`)}
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const isEnabled = tool.status === "enabled"
const isDisabled = tool.status === "disabled"
const isBlocked = tool.status === "blocked"
return (
<Card
key={tool.name}
className={cn(
"group cursor-default transition-colors",
isBlocked
? "border-amber-200/80 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/60",
isDisabled && "opacity-80",
)}
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CardTitle className="font-mono text-sm font-semibold break-all">
{tool.name}
</CardTitle>
<ToolStatusBadge status={tool.status} />
</div>
<CardDescription className="text-muted-foreground/80 mt-2 text-xs leading-relaxed break-words sm:text-sm">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center pt-1 pl-2 sm:pt-0">
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) =>
toggleMutation.mutate({
name: tool.name,
enabled: checked,
})
}
/>
</div>
</div>
</CardHeader>
{reasonText && (
<CardContent className="pt-0 pb-4">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
{reasonText}
</div>
</CardContent>
)}
</Card>
)
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}
+20 -3
View File
@@ -6,6 +6,7 @@ import {
IconKey,
IconListDetails,
IconMessageCircle,
IconSearch,
IconSettings,
IconSparkles,
IconTools,
@@ -24,14 +25,15 @@ import {
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarFooter,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar"
import { useSidebarChannels } from "@/hooks/use-sidebar-channels"
@@ -71,6 +73,7 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const routerState = useRouterState()
const { i18n, t } = useTranslation()
const { isMobile, setOpenMobile } = useSidebar()
const currentPath = routerState.location.pathname
const {
channelItems,
@@ -88,6 +91,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
})
const versionText = versionInfo?.version ?? t("footer.version_unknown")
const handleNavItemClick = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
}
}, [isMobile, setOpenMobile])
const navGroups: NavGroup[] = React.useMemo(() => {
return [
@@ -133,6 +141,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{
...baseNavGroups[2],
items: [
{
title: "navigation.hub",
url: "/agent/hub",
icon: IconSearch,
translateTitle: true,
},
{
title: "navigation.skills",
url: "/agent/skills",
@@ -199,7 +213,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton
asChild
isActive={isActive}
data-tour={item.url === "/models" ? "models-nav" : undefined}
onClick={handleNavItemClick}
data-tour={
item.url === "/models" ? "models-nav" : undefined
}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
>
<Link to={item.url}>
@@ -246,7 +263,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Collapsible>
))}
</SidebarContent>
<SidebarFooter className="border-t-border/30 group-data-[collapsible=icon]:hidden border-t px-3 py-2">
<SidebarFooter className="border-t-border/30 border-t px-3 py-2 group-data-[collapsible=icon]:hidden">
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
<div className="truncate" title={versionText}>
<span className="text-foreground/80">{t("footer.version")}:</span>{" "}
@@ -1,319 +0,0 @@
import {
IconFileInfo,
IconLoader2,
IconPlus,
IconTrash,
} from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { type ChangeEvent, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import { toast } from "sonner"
import {
type SkillSupportItem,
deleteSkill,
getSkill,
getSkills,
importSkill,
} from "@/api/skills"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
export function SkillsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const importInputRef = useRef<HTMLInputElement | null>(null)
const [selectedSkill, setSelectedSkill] = useState<SkillSupportItem | null>(
null,
)
const [skillPendingDelete, setSkillPendingDelete] =
useState<SkillSupportItem | null>(null)
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: getSkills,
})
const {
data: selectedSkillDetail,
isLoading: isSkillDetailLoading,
error: skillDetailError,
} = useQuery({
queryKey: ["skills", selectedSkill?.name],
queryFn: () => getSkill(selectedSkill!.name),
enabled: selectedSkill !== null,
})
const importMutation = useMutation({
mutationFn: async (file: File) => importSkill(file),
onSuccess: () => {
toast.success(t("pages.agent.skills.import_success"))
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.import_error"),
)
},
})
const deleteMutation = useMutation({
mutationFn: async (name: string) => deleteSkill(name),
onSuccess: (_, deletedName) => {
toast.success(t("pages.agent.skills.delete_success"))
setSkillPendingDelete(null)
if (
selectedSkill?.name === deletedName &&
selectedSkill.source === "workspace"
) {
setSelectedSkill(null)
}
void queryClient.invalidateQueries({ queryKey: ["skills"] })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.skills.delete_error"),
)
},
})
const handleImportClick = () => {
importInputRef.current?.click()
}
const handleImportFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
importMutation.mutate(file)
event.target.value = ""
}
return (
<div className="flex h-full flex-col">
<PageHeader
title={t("navigation.skills")}
children={
<>
<input
ref={importInputRef}
type="file"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={handleImportFileChange}
/>
<Button
variant="outline"
onClick={handleImportClick}
disabled={importMutation.isPending}
>
{importMutation.isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconPlus className="size-4" />
)}
{t("pages.agent.skills.import")}
</Button>
</>
}
/>
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="space-y-5">
<p className="text-muted-foreground text-sm">
{t("pages.agent.skills.description")}
</p>
{data?.skills.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{data.skills.map((skill) => (
<Card
key={`${skill.source}:${skill.name}`}
className="border-border/60 gap-4"
size="sm"
>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="font-semibold">
{skill.name}
</CardTitle>
<CardDescription className="mt-3">
{skill.description ||
t("pages.agent.skills.no_description")}
</CardDescription>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setSelectedSkill(skill)}
title={t("pages.agent.skills.view")}
>
<IconFileInfo className="size-4" />
</Button>
{skill.source === "workspace" ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setSkillPendingDelete(skill)}
title={t("pages.agent.skills.delete")}
>
<IconTrash className="size-4" />
</Button>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
{t("pages.agent.skills.path")}
</div>
<div className="bg-muted text-foreground overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
{skill.path}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
{t("pages.agent.skills.empty")}
</CardContent>
</Card>
)}
</section>
)}
</div>
</div>
<Sheet
open={selectedSkill !== null}
onOpenChange={(open) => {
if (!open) setSelectedSkill(null)
}}
>
<SheetContent
side="right"
className="w-full gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>
{selectedSkill?.name || t("pages.agent.skills.viewer_title")}
</SheetTitle>
<SheetDescription>
{selectedSkill?.description ||
t("pages.agent.skills.viewer_description")}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-auto px-6 py-5">
{isSkillDetailLoading ? (
<div className="text-muted-foreground text-sm">
{t("pages.agent.skills.loading_detail")}
</div>
) : skillDetailError ? (
<div className="text-destructive text-sm">
{t("pages.agent.skills.load_detail_error")}
</div>
) : selectedSkillDetail ? (
<div className="space-y-5">
<div className="prose prose-sm dark:prose-invert prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{selectedSkillDetail.content}
</ReactMarkdown>
</div>
</div>
) : null}
</div>
</SheetContent>
</Sheet>
<AlertDialog
open={skillPendingDelete !== null}
onOpenChange={(open) => {
if (!open) setSkillPendingDelete(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("pages.agent.skills.delete_title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pages.agent.skills.delete_description", {
name: skillPendingDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleteMutation.isPending || !skillPendingDelete}
onClick={() => {
if (skillPendingDelete)
deleteMutation.mutate(skillPendingDelete.name)
}}
>
{deleteMutation.isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : (
<IconTrash className="size-4" />
)}
{t("pages.agent.skills.delete_confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -1,192 +0,0 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ToolSupportItem, getTools, setToolEnabled } from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
const groupedTools = (() => {
if (!data) return [] as Array<[string, ToolSupportItem[]]>
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return Array.from(buckets.entries())
})()
return (
<div className="flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<div className="flex-1 overflow-auto px-6 py-3">
<div className="w-full max-w-6xl space-y-6">
{isLoading ? (
<div className="text-muted-foreground py-6 text-sm">
{t("labels.loading")}
</div>
) : error ? (
<div className="text-destructive py-6 text-sm">
{t("pages.agent.load_error")}
</div>
) : (
<section className="space-y-5">
<p className="text-muted-foreground mt-1 text-sm">
{t("pages.agent.tools.description")}
</p>
{data?.tools.length ? (
groupedTools.map(([category, items]) => (
<div key={category} className="space-y-3">
<div className="text-foreground/85 text-sm font-semibold tracking-wide">
{t(`pages.agent.tools.categories.${category}`)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const nextEnabled = tool.status !== "enabled"
return (
<Card
key={tool.name}
className={cn(
"gap-4 border transition-colors",
tool.status === "enabled" &&
"border-emerald-200/70 bg-emerald-50/50",
tool.status === "blocked" &&
"border-amber-200/80 bg-amber-50/60",
tool.status === "disabled" &&
"border-border/60 bg-card/70",
)}
size="sm"
>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="font-mono text-sm break-all">
{tool.name}
</CardTitle>
<CardDescription className="mt-1 break-words">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
<ToolStatusBadge status={tool.status} />
<Button
variant={
nextEnabled ? "default" : "outline"
}
size="sm"
disabled={isPending}
onClick={() =>
toggleMutation.mutate({
name: tool.name,
enabled: nextEnabled,
})
}
>
{isPending ? (
<IconLoader2 className="size-4 animate-spin" />
) : null}
{nextEnabled
? t("pages.agent.tools.enable")
: t("pages.agent.tools.disable")}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-muted-foreground text-xs">
{t("pages.agent.tools.config_key", {
key: tool.config_key,
})}
</div>
{reasonText ? (
<div className="text-sm text-amber-800">
{reasonText}
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
</div>
))
) : (
<Card className="border-dashed">
<CardContent className="text-muted-foreground py-10 text-center text-sm">
{t("pages.agent.tools.empty")}
</CardContent>
</Card>
)}
</section>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold",
status === "enabled" && "bg-emerald-100 text-emerald-700",
status === "blocked" && "bg-amber-100 text-amber-700",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}
+163
View File
@@ -0,0 +1,163 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<IconX
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+74 -8
View File
@@ -5,6 +5,7 @@
"models": "Models",
"credentials": "Credentials",
"agent_group": "Agent",
"hub": "Hub",
"skills": "Skills",
"tools": "Tools",
"services": "Services",
@@ -398,11 +399,18 @@
"agent": {
"load_error": "Failed to load agent support information.",
"skills": {
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
"empty": "No skills are currently available.",
"install_success": "Installed {{name}}.",
"install_error": "Failed to install skill.",
"search_placeholder": "Search by name, description, or registry",
"source_label": "Type",
"sort_label": "Sort",
"import": "Import Skill",
"import_success": "Skill imported.",
"import_error": "Failed to import skill.",
"import_invalid_type": "Only Markdown or ZIP skill files are supported.",
"import_invalid_size": "Skill file must be 1 MB or smaller.",
"import_constraints": "Import a Markdown or ZIP skill file up to 1 MB",
"view": "View",
"delete": "Delete",
"delete_title": "Delete Skill?",
@@ -412,20 +420,78 @@
"delete_error": "Failed to delete skill.",
"viewer_title": "Skill Content",
"viewer_description": "Read the current effective SKILL.md content here.",
"loading_detail": "Loading skill content...",
"load_detail_error": "Failed to load skill content.",
"path": "Skill Path",
"no_description": "No description provided."
"no_description": "No description provided.",
"no_results": "No skills matched the current filters.",
"dropzone_title": "Import Into Workspace",
"dropzone_description": "Drag a skill file here or pick one from disk.",
"dropzone_label": "Drop a skill file here",
"dropzone_active": "Release to import this skill",
"dropzone_release": "The skill will be normalized and saved into the workspace skills directory.",
"marketplace_title": "Discover Skills",
"marketplace_description": "Search the skill registries and install useful skills into this workspace",
"marketplace_search_placeholder": "Search for capabilities like github, docker, database...",
"marketplace_search_action": "Search",
"marketplace_search_status": "Search Status",
"marketplace_install_status": "Install Status",
"marketplace_notice_title": "Security Notice",
"marketplace_notice_body": "Registry skills are third-party content. Review the author, page URL, instructions, and any required code or credentials before installing.",
"marketplace_status_disabled": "Disabled. Enable the corresponding tool on the Tools page first.",
"marketplace_status_enable_hint": "Enable the related tool on the Tools page first.",
"marketplace_search_error": "Failed to search registries.",
"marketplace_loading_results": "Searching skills...",
"marketplace_loading_more": "Loading more skills...",
"marketplace_results_title": "{{count}} results for “{{query}}”",
"marketplace_results_hint": "Registry results install into the current workspace.",
"marketplace_install_action": "Install",
"marketplace_installed": "Installed",
"marketplace_view_installed": "View Local",
"marketplace_installed_hint": "Already available in this workspace as “{{name}}”.",
"marketplace_empty_results": "No installable skills matched “{{query}}”.",
"marketplace_idle": "Search for a capability to discover installable skills from configured registries.",
"marketplace_unavailable": "Registry search is currently unavailable. Check the Skills tools configuration.",
"sort": {
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"source": "Type"
},
"origin": {
"all": "All Types",
"builtin": "Builtin",
"third_party": "Third-Party",
"manual": "Manual"
},
"summary": {
"total": "Total Skills"
},
"detail_tabs": {
"preview": "Preview",
"raw": "Raw",
"meta": "Metadata"
},
"metadata": {
"name": "Name",
"description": "Description",
"registry": "Registry",
"url": "URL",
"version": "Installed Version",
"lines": "Line Count",
"characters": "Character Count"
}
},
"tools": {
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
"search_placeholder": "Search tools...",
"no_results": "No tools match your criteria.",
"filter": {
"all": "All Status",
"enabled": "Enabled only",
"disabled": "Disabled only",
"blocked": "Blocked only"
},
"empty": "No tools are available.",
"enable": "Enable",
"disable": "Disable",
"enable_success": "Tool enabled.",
"disable_success": "Tool disabled.",
"toggle_error": "Failed to update tool state.",
"config_key": "Controlled by tools.{{key}}",
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
+74 -8
View File
@@ -5,6 +5,7 @@
"models": "模型",
"credentials": "凭据",
"agent_group": "智能体",
"hub": "Hub",
"skills": "技能",
"tools": "工具",
"services": "服务",
@@ -398,11 +399,18 @@
"agent": {
"load_error": "加载 Agent 支持信息失败。",
"skills": {
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
"empty": "当前没有可用技能。",
"install_success": "已安装 {{name}}。",
"install_error": "安装技能失败。",
"search_placeholder": "按名称、描述或技能源搜索",
"source_label": "类型",
"sort_label": "排序",
"import": "导入技能",
"import_success": "技能导入成功。",
"import_error": "导入技能失败。",
"import_invalid_type": "仅支持导入 Markdown 或 ZIP 技能文件。",
"import_invalid_size": "技能文件大小不能超过 1 MB。",
"import_constraints": "支持导入最大 1 MB 的 Markdown 或 ZIP 文件",
"view": "查看",
"delete": "删除",
"delete_title": "删除技能?",
@@ -412,20 +420,78 @@
"delete_error": "删除技能失败。",
"viewer_title": "技能内容",
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
"loading_detail": "正在加载技能内容...",
"load_detail_error": "加载技能内容失败。",
"path": "技能路径",
"no_description": "未提供描述。"
"no_description": "未提供描述。",
"no_results": "没有技能匹配当前筛选条件。",
"dropzone_title": "导入到工作区",
"dropzone_description": "将技能文件拖到这里,或从本地选择一个文件。",
"dropzone_label": "将技能文件拖到这里",
"dropzone_active": "松开即可导入该技能",
"dropzone_release": "导入后会自动规范化内容,并保存到工作区技能目录。",
"marketplace_title": "安装技能",
"marketplace_description": "搜索第三方技能源,并将技能安装到当前工作区",
"marketplace_search_placeholder": "搜索 github、docker、database 等技能",
"marketplace_search_action": "搜索",
"marketplace_search_status": "搜索状态",
"marketplace_install_status": "安装状态",
"marketplace_notice_title": "安全提示",
"marketplace_notice_body": "搜索结果中的 skills 属于第三方内容。安装前请先确认作者、页面 URL、说明文档,以及它要求执行的代码或使用的凭据是否可信。",
"marketplace_status_disabled": "当前未启用,请先在工具页启用对应工具。",
"marketplace_status_enable_hint": "请先在工具页启用相关工具。",
"marketplace_search_error": "搜索技能源失败。",
"marketplace_loading_results": "正在搜索技能...",
"marketplace_loading_more": "正在加载更多技能...",
"marketplace_results_title": "“{{query}}” 共找到 {{count}} 个结果",
"marketplace_results_hint": "搜索结果会安装到当前工作区。",
"marketplace_install_action": "安装",
"marketplace_installed": "已安装",
"marketplace_view_installed": "查看本地技能",
"marketplace_installed_hint": "该技能已在当前工作区中可用,名称为「{{name}}」。",
"marketplace_empty_results": "没有找到与“{{query}}”匹配的可安装技能。",
"marketplace_idle": "输入一个关键词,搜索可安装的第三方技能。",
"marketplace_unavailable": "当前无法使用技能搜索,请检查 Skills 相关工具配置。",
"sort": {
"name_asc": "名称(A-Z",
"name_desc": "名称(Z-A",
"source": "按类型"
},
"origin": {
"all": "全部类型",
"builtin": "内置",
"third_party": "第三方",
"manual": "手动导入"
},
"summary": {
"total": "技能总数"
},
"detail_tabs": {
"preview": "预览",
"raw": "原始内容",
"meta": "元数据"
},
"metadata": {
"name": "名称",
"description": "描述",
"registry": "来源平台",
"url": "链接地址",
"version": "已安装版本",
"lines": "行数",
"characters": "字符数"
}
},
"tools": {
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
"search_placeholder": "搜索工具...",
"no_results": "没有找到符合条件的工具",
"filter": {
"all": "所有状态",
"enabled": "已启用",
"disabled": "已禁用",
"blocked": "被阻塞"
},
"empty": "当前没有可用工具。",
"enable": "启用",
"disable": "禁用",
"enable_success": "工具已启用。",
"disable_success": "工具已禁用。",
"toggle_error": "更新工具状态失败。",
"config_key": "由 tools.{{key}} 控制",
"status": {
"enabled": "已启用",
"disabled": "已禁用",
+21
View File
@@ -21,6 +21,7 @@ import { Route as ConfigRawRouteImport } from './routes/config.raw'
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
import { Route as AgentHubRouteImport } from './routes/agent/hub'
const ModelsRoute = ModelsRouteImport.update({
id: '/models',
@@ -82,6 +83,11 @@ const AgentSkillsRoute = AgentSkillsRouteImport.update({
path: '/skills',
getParentRoute: () => AgentRoute,
} as any)
const AgentHubRoute = AgentHubRouteImport.update({
id: '/hub',
path: '/hub',
getParentRoute: () => AgentRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -92,6 +98,7 @@ export interface FileRoutesByFullPath {
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@@ -106,6 +113,7 @@ export interface FileRoutesByTo {
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@@ -121,6 +129,7 @@ export interface FileRoutesById {
'/launcher-login': typeof LauncherLoginRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
'/channels/$name': typeof ChannelsNameRoute
@@ -137,6 +146,7 @@ export interface FileRouteTypes {
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@@ -151,6 +161,7 @@ export interface FileRouteTypes {
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@@ -165,6 +176,7 @@ export interface FileRouteTypes {
| '/launcher-login'
| '/logs'
| '/models'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
| '/channels/$name'
@@ -268,6 +280,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentSkillsRouteImport
parentRoute: typeof AgentRoute
}
'/agent/hub': {
id: '/agent/hub'
path: '/hub'
fullPath: '/agent/hub'
preLoaderRoute: typeof AgentHubRouteImport
parentRoute: typeof AgentRoute
}
}
}
@@ -284,11 +303,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
)
interface AgentRouteChildren {
AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute
}
const AgentRouteChildren: AgentRouteChildren = {
AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute,
}
+1 -1
View File
@@ -15,7 +15,7 @@ function AgentLayout() {
})
if (pathname === "/agent") {
return <Navigate to="/agent/skills" />
return <Navigate to="/agent/hub" />
}
return <Outlet />
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { HubPage } from "@/components/agent/hub/hub-page"
export const Route = createFileRoute("/agent/hub")({
component: AgentHubRoute,
})
function AgentHubRoute() {
return <HubPage />
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { SkillsPage } from "@/components/skills/skills-page"
import { SkillsPage } from "@/components/agent/skills/skills-page"
export const Route = createFileRoute("/agent/skills")({
component: AgentSkillsRoute,
+1 -1
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { ToolsPage } from "@/components/tools/tools-page"
import { ToolsPage } from "@/components/agent/tools/tools-page"
export const Route = createFileRoute("/agent/tools")({
component: AgentToolsRoute,