mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
+833
-54
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function AgentLayout() {
|
||||
})
|
||||
|
||||
if (pathname === "/agent") {
|
||||
return <Navigate to="/agent/skills" />
|
||||
return <Navigate to="/agent/hub" />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
Reference in New Issue
Block a user