feat(agent): add skills and tools management APIs and UI
- add backend APIs to list, view, import, and delete skills - add tool status and toggle endpoints with dependency-aware config updates - add agent skills/tools pages, routes, sidebar entries, and i18n strings - add backend tests for the new skills and tools flows
This commit is contained in:
parent
717da809fd
commit
75ae3fdb80
18 changed files with 2169 additions and 2 deletions
|
|
@ -354,7 +354,11 @@ func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
|||
}
|
||||
|
||||
statusRec := httptest.NewRecorder()
|
||||
statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), nil)
|
||||
statusReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
|
||||
nil,
|
||||
)
|
||||
mux.ServeHTTP(statusRec, statusReq)
|
||||
|
||||
if statusRec.Code != http.StatusOK {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
// Channel catalog (for frontend navigation/config pages)
|
||||
h.registerChannelRoutes(mux)
|
||||
|
||||
// Skills and tools support/actions
|
||||
h.registerSkillRoutes(mux)
|
||||
h.registerToolRoutes(mux)
|
||||
|
||||
// OS startup / launch-at-login
|
||||
h.registerStartupRoutes(mux)
|
||||
|
||||
|
|
|
|||
331
web/backend/api/skills.go
Normal file
331
web/backend/api/skills.go
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type skillSupportResponse struct {
|
||||
Skills []skills.SkillInfo `json:"skills"`
|
||||
}
|
||||
|
||||
type skillDetailResponse struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
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)*`)
|
||||
)
|
||||
|
||||
func (h *Handler) registerSkillRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/skills", h.handleListSkills)
|
||||
mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill)
|
||||
mux.HandleFunc("POST /api/skills/import", h.handleImportSkill)
|
||||
mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
loader := newSkillsLoader(cfg.WorkspacePath())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(skillSupportResponse{
|
||||
Skills: loader.ListSkills(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
loader := newSkillsLoader(cfg.WorkspacePath())
|
||||
name := r.PathValue("name")
|
||||
allSkills := loader.ListSkills()
|
||||
|
||||
for _, skill := range allSkills {
|
||||
if skill.Name != name {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := loadSkillContent(skill.Path)
|
||||
if err != nil {
|
||||
http.Error(w, "Skill content not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Skill not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseMultipartForm(2 << 20)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
uploadedFile, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "file is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer uploadedFile.Close()
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(content) > 1<<20 {
|
||||
http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
skillName, err := normalizeImportedSkillName(fileHeader.Filename, content)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
loader := newSkillsLoader(cfg.WorkspacePath())
|
||||
name := r.PathValue("name")
|
||||
for _, skill := range loader.ListSkills() {
|
||||
if skill.Name != name {
|
||||
continue
|
||||
}
|
||||
if skill.Source != "workspace" {
|
||||
http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Skill not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func newSkillsLoader(workspace string) *skills.SkillsLoader {
|
||||
return skills.NewSkillsLoader(
|
||||
workspace,
|
||||
filepath.Join(globalConfigDir(), "skills"),
|
||||
builtinSkillsDir(),
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeImportedSkillName(filename 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(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
|
||||
}
|
||||
raw = strings.ToLower(raw)
|
||||
raw = strings.ReplaceAll(raw, "_", "-")
|
||||
raw = strings.ReplaceAll(raw, " ", "-")
|
||||
raw = skillNameSanitizer.ReplaceAllString(raw, "-")
|
||||
raw = strings.Trim(raw, "-")
|
||||
raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-")
|
||||
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("skill name is required in frontmatter or filename")
|
||||
}
|
||||
if len(raw) > 64 {
|
||||
return "", fmt.Errorf("skill name exceeds 64 characters")
|
||||
}
|
||||
matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw)
|
||||
if err != nil || !matched {
|
||||
return "", fmt.Errorf("skill name must be alphanumeric with hyphens")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func normalizeImportedSkillContent(content []byte, skillName string) []byte {
|
||||
raw := strings.ReplaceAll(string(content), "\r\n", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||
|
||||
metadata, body := extractImportedSkillMetadata(raw)
|
||||
description := strings.TrimSpace(metadata["description"])
|
||||
if description == "" {
|
||||
description = inferImportedSkillDescription(body)
|
||||
}
|
||||
if description == "" {
|
||||
description = "Imported skill"
|
||||
}
|
||||
if len(description) > 1024 {
|
||||
description = strings.TrimSpace(description[:1024])
|
||||
}
|
||||
|
||||
body = strings.TrimLeft(body, "\n")
|
||||
var builder strings.Builder
|
||||
builder.WriteString("---\n")
|
||||
builder.WriteString("name: ")
|
||||
builder.WriteString(skillName)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("description: ")
|
||||
builder.WriteString(description)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("---\n\n")
|
||||
builder.WriteString(body)
|
||||
if !strings.HasSuffix(builder.String(), "\n") {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func extractImportedSkillMetadata(raw string) (map[string]string, string) {
|
||||
matches := importedSkillFrontmatter.FindStringSubmatch(raw)
|
||||
if len(matches) != 2 {
|
||||
return map[string]string{}, raw
|
||||
}
|
||||
meta := parseImportedSkillYAML(matches[1])
|
||||
body := importedSkillFrontmatter.ReplaceAllString(raw, "")
|
||||
return meta, body
|
||||
}
|
||||
|
||||
func parseImportedSkillYAML(frontmatter string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(frontmatter, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func inferImportedSkillDescription(body string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimLeft(line, "#-*0123456789. ")
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loadSkillContent(path string) (string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil
|
||||
}
|
||||
|
||||
func globalConfigDir() string {
|
||||
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||
return home
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
|
||||
func builtinSkillsDir() string {
|
||||
if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" {
|
||||
return path
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(wd, "skills")
|
||||
}
|
||||
336
web/backend/api/skills_test.go
Normal file
336
web/backend/api/skills_test.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestHandleListSkills(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||
cfg.Agents.Defaults.Workspace = workspace
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(workspace skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"),
|
||||
[]byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(workspace skill) error = %v", err)
|
||||
}
|
||||
|
||||
globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill")
|
||||
if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(global skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(globalSkillDir, "SKILL.md"),
|
||||
[]byte("---\nname: global-skill\ndescription: Global skill\n---\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(global skill) error = %v", err)
|
||||
}
|
||||
|
||||
builtinRoot := filepath.Join(t.TempDir(), "builtin-skills")
|
||||
oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS")
|
||||
if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil {
|
||||
t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if oldBuiltin == "" {
|
||||
_ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS")
|
||||
} else {
|
||||
_ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin)
|
||||
}
|
||||
}()
|
||||
|
||||
builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill")
|
||||
if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(builtin skill) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(builtinSkillDir, "SKILL.md"),
|
||||
[]byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(builtin skill) error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp skillSupportResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(resp.Skills) != 3 {
|
||||
t.Fatalf("skills count = %d, want 3", len(resp.Skills))
|
||||
}
|
||||
|
||||
gotSkills := make(map[string]string, len(resp.Skills))
|
||||
for _, skill := range resp.Skills {
|
||||
gotSkills[skill.Name] = skill.Source
|
||||
}
|
||||
if gotSkills["workspace-skill"] != "workspace" {
|
||||
t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"])
|
||||
}
|
||||
if gotSkills["global-skill"] != "global" {
|
||||
t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"])
|
||||
}
|
||||
if gotSkills["builtin-skill"] != "builtin" {
|
||||
t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSkill(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||
cfg.Agents.Defaults.Workspace = workspace
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", "viewer-skill")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte(
|
||||
"---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n",
|
||||
),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp skillDetailResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" {
|
||||
t.Fatalf("unexpected response: %#v", resp)
|
||||
}
|
||||
if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" {
|
||||
t.Fatalf("content = %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetSkillUsesResolvedPath(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||
cfg.Agents.Defaults.Workspace = workspace
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", "folder-name")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp skillDetailResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if resp.Name != "display-name" {
|
||||
t.Fatalf("resp.Name = %q, want display-name", resp.Name)
|
||||
}
|
||||
if resp.Content != "# Display Name\n" {
|
||||
t.Fatalf("content = %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleImportSkill(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||
cfg.Agents.Defaults.Workspace = workspace
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", "Plain Skill.md")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile() error = %v", err)
|
||||
}
|
||||
_, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteString() error = %v", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md")
|
||||
content, err := os.ReadFile(skillFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n"
|
||||
if string(content) != expected {
|
||||
t.Fatalf("saved skill content mismatch:\n%s", string(content))
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil)
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
|
||||
}
|
||||
var listResp skillSupportResponse
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatalf("Unmarshal list response error = %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, skill := range listResp.Skills {
|
||||
if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteSkill(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
workspace := filepath.Join(t.TempDir(), "workspace")
|
||||
cfg.Agents.Defaults.Workspace = workspace
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", "delete-me")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: delete-me\ndescription: delete me\n---\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("skill directory should be removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
323
web/backend/api/tools.go
Normal file
323
web/backend/api/tools.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type toolCatalogEntry struct {
|
||||
Name string
|
||||
Description string
|
||||
Category string
|
||||
ConfigKey string
|
||||
}
|
||||
|
||||
type toolSupportItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
ConfigKey string `json:"config_key"`
|
||||
Status string `json:"status"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
}
|
||||
|
||||
type toolSupportResponse struct {
|
||||
Tools []toolSupportItem `json:"tools"`
|
||||
}
|
||||
|
||||
type toolStateRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
var toolCatalog = []toolCatalogEntry{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read file content from the workspace or explicitly allowed paths.",
|
||||
Category: "filesystem",
|
||||
ConfigKey: "read_file",
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "Create or overwrite files within the writable workspace scope.",
|
||||
Category: "filesystem",
|
||||
ConfigKey: "write_file",
|
||||
},
|
||||
{
|
||||
Name: "list_dir",
|
||||
Description: "Inspect directories and enumerate files available to the agent.",
|
||||
Category: "filesystem",
|
||||
ConfigKey: "list_dir",
|
||||
},
|
||||
{
|
||||
Name: "edit_file",
|
||||
Description: "Apply targeted edits to existing files without rewriting everything.",
|
||||
Category: "filesystem",
|
||||
ConfigKey: "edit_file",
|
||||
},
|
||||
{
|
||||
Name: "append_file",
|
||||
Description: "Append content to the end of an existing file.",
|
||||
Category: "filesystem",
|
||||
ConfigKey: "append_file",
|
||||
},
|
||||
{
|
||||
Name: "exec",
|
||||
Description: "Run shell commands inside the configured workspace sandbox.",
|
||||
Category: "filesystem",
|
||||
ConfigKey: "exec",
|
||||
},
|
||||
{
|
||||
Name: "cron",
|
||||
Description: "Schedule one-time or recurring reminders, jobs, and shell commands.",
|
||||
Category: "automation",
|
||||
ConfigKey: "cron",
|
||||
},
|
||||
{
|
||||
Name: "web_search",
|
||||
Description: "Search the web using the configured providers.",
|
||||
Category: "web",
|
||||
ConfigKey: "web",
|
||||
},
|
||||
{
|
||||
Name: "web_fetch",
|
||||
Description: "Fetch and summarize the contents of a webpage.",
|
||||
Category: "web",
|
||||
ConfigKey: "web_fetch",
|
||||
},
|
||||
{
|
||||
Name: "message",
|
||||
Description: "Send a follow-up message back to the active user or chat.",
|
||||
Category: "communication",
|
||||
ConfigKey: "message",
|
||||
},
|
||||
{
|
||||
Name: "send_file",
|
||||
Description: "Send an outbound file or media attachment to the active chat.",
|
||||
Category: "communication",
|
||||
ConfigKey: "send_file",
|
||||
},
|
||||
{
|
||||
Name: "find_skills",
|
||||
Description: "Search external skill registries for installable skills.",
|
||||
Category: "skills",
|
||||
ConfigKey: "find_skills",
|
||||
},
|
||||
{
|
||||
Name: "install_skill",
|
||||
Description: "Install a skill into the current workspace from a registry.",
|
||||
Category: "skills",
|
||||
ConfigKey: "install_skill",
|
||||
},
|
||||
{
|
||||
Name: "spawn",
|
||||
Description: "Launch a background subagent for long-running or delegated work.",
|
||||
Category: "agents",
|
||||
ConfigKey: "spawn",
|
||||
},
|
||||
{
|
||||
Name: "i2c",
|
||||
Description: "Interact with I2C hardware devices exposed on the host.",
|
||||
Category: "hardware",
|
||||
ConfigKey: "i2c",
|
||||
},
|
||||
{
|
||||
Name: "spi",
|
||||
Description: "Interact with SPI hardware devices exposed on the host.",
|
||||
Category: "hardware",
|
||||
ConfigKey: "spi",
|
||||
},
|
||||
{
|
||||
Name: "tool_search_tool_regex",
|
||||
Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.",
|
||||
Category: "discovery",
|
||||
ConfigKey: "mcp.discovery.use_regex",
|
||||
},
|
||||
{
|
||||
Name: "tool_search_tool_bm25",
|
||||
Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.",
|
||||
Category: "discovery",
|
||||
ConfigKey: "mcp.discovery.use_bm25",
|
||||
},
|
||||
}
|
||||
|
||||
func (h *Handler) registerToolRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/tools", h.handleListTools)
|
||||
mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(toolSupportResponse{
|
||||
Tools: buildToolSupport(cfg),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var req toolStateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func buildToolSupport(cfg *config.Config) []toolSupportItem {
|
||||
items := make([]toolSupportItem, 0, len(toolCatalog))
|
||||
for _, entry := range toolCatalog {
|
||||
status := "disabled"
|
||||
reasonCode := ""
|
||||
|
||||
switch entry.Name {
|
||||
case "find_skills", "install_skill":
|
||||
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||
if cfg.Tools.IsToolEnabled("skills") {
|
||||
status = "enabled"
|
||||
} else {
|
||||
status = "blocked"
|
||||
reasonCode = "requires_skills"
|
||||
}
|
||||
}
|
||||
case "spawn":
|
||||
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||
if cfg.Tools.IsToolEnabled("subagent") {
|
||||
status = "enabled"
|
||||
} else {
|
||||
status = "blocked"
|
||||
reasonCode = "requires_subagent"
|
||||
}
|
||||
}
|
||||
case "tool_search_tool_regex":
|
||||
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex)
|
||||
case "tool_search_tool_bm25":
|
||||
status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25)
|
||||
case "i2c", "spi":
|
||||
status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey))
|
||||
default:
|
||||
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||
status = "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, toolSupportItem{
|
||||
Name: entry.Name,
|
||||
Description: entry.Description,
|
||||
Category: entry.Category,
|
||||
ConfigKey: entry.ConfigKey,
|
||||
Status: status,
|
||||
ReasonCode: reasonCode,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func resolveHardwareToolSupport(enabled bool) (string, string) {
|
||||
if !enabled {
|
||||
return "disabled", ""
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
return "blocked", "requires_linux"
|
||||
}
|
||||
return "enabled", ""
|
||||
}
|
||||
|
||||
func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) {
|
||||
if !cfg.Tools.IsToolEnabled("mcp") {
|
||||
return "disabled", ""
|
||||
}
|
||||
if !cfg.Tools.MCP.Discovery.Enabled {
|
||||
return "blocked", "requires_mcp_discovery"
|
||||
}
|
||||
if !methodEnabled {
|
||||
return "disabled", ""
|
||||
}
|
||||
return "enabled", ""
|
||||
}
|
||||
|
||||
func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
|
||||
switch toolName {
|
||||
case "read_file":
|
||||
cfg.Tools.ReadFile.Enabled = enabled
|
||||
case "write_file":
|
||||
cfg.Tools.WriteFile.Enabled = enabled
|
||||
case "list_dir":
|
||||
cfg.Tools.ListDir.Enabled = enabled
|
||||
case "edit_file":
|
||||
cfg.Tools.EditFile.Enabled = enabled
|
||||
case "append_file":
|
||||
cfg.Tools.AppendFile.Enabled = enabled
|
||||
case "exec":
|
||||
cfg.Tools.Exec.Enabled = enabled
|
||||
case "cron":
|
||||
cfg.Tools.Cron.Enabled = enabled
|
||||
case "web_search":
|
||||
cfg.Tools.Web.Enabled = enabled
|
||||
case "web_fetch":
|
||||
cfg.Tools.WebFetch.Enabled = enabled
|
||||
case "message":
|
||||
cfg.Tools.Message.Enabled = enabled
|
||||
case "send_file":
|
||||
cfg.Tools.SendFile.Enabled = enabled
|
||||
case "find_skills":
|
||||
cfg.Tools.FindSkills.Enabled = enabled
|
||||
if enabled {
|
||||
cfg.Tools.Skills.Enabled = true
|
||||
}
|
||||
case "install_skill":
|
||||
cfg.Tools.InstallSkill.Enabled = enabled
|
||||
if enabled {
|
||||
cfg.Tools.Skills.Enabled = true
|
||||
}
|
||||
case "spawn":
|
||||
cfg.Tools.Spawn.Enabled = enabled
|
||||
if enabled {
|
||||
cfg.Tools.Subagent.Enabled = true
|
||||
}
|
||||
case "i2c":
|
||||
cfg.Tools.I2C.Enabled = enabled
|
||||
case "spi":
|
||||
cfg.Tools.SPI.Enabled = enabled
|
||||
case "tool_search_tool_regex":
|
||||
cfg.Tools.MCP.Discovery.UseRegex = enabled
|
||||
if enabled {
|
||||
cfg.Tools.MCP.Enabled = true
|
||||
cfg.Tools.MCP.Discovery.Enabled = true
|
||||
}
|
||||
case "tool_search_tool_bm25":
|
||||
cfg.Tools.MCP.Discovery.UseBM25 = enabled
|
||||
if enabled {
|
||||
cfg.Tools.MCP.Enabled = true
|
||||
cfg.Tools.MCP.Discovery.Enabled = true
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("tool %q cannot be updated", toolName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
198
web/backend/api/tools_test.go
Normal file
198
web/backend/api/tools_test.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestHandleListTools(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Tools.ReadFile.Enabled = true
|
||||
cfg.Tools.WriteFile.Enabled = false
|
||||
cfg.Tools.Cron.Enabled = true
|
||||
cfg.Tools.FindSkills.Enabled = true
|
||||
cfg.Tools.Skills.Enabled = true
|
||||
cfg.Tools.Spawn.Enabled = true
|
||||
cfg.Tools.Subagent.Enabled = false
|
||||
cfg.Tools.MCP.Enabled = true
|
||||
cfg.Tools.MCP.Discovery.Enabled = true
|
||||
cfg.Tools.MCP.Discovery.UseRegex = true
|
||||
cfg.Tools.MCP.Discovery.UseBM25 = false
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tools", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp toolSupportResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
gotTools := make(map[string]toolSupportItem, len(resp.Tools))
|
||||
for _, tool := range resp.Tools {
|
||||
gotTools[tool.Name] = tool
|
||||
}
|
||||
if gotTools["read_file"].Status != "enabled" {
|
||||
t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status)
|
||||
}
|
||||
if gotTools["write_file"].Status != "disabled" {
|
||||
t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status)
|
||||
}
|
||||
if gotTools["cron"].Status != "enabled" {
|
||||
t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status)
|
||||
}
|
||||
if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" {
|
||||
t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"])
|
||||
}
|
||||
if gotTools["find_skills"].Status != "enabled" {
|
||||
t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status)
|
||||
}
|
||||
if gotTools["tool_search_tool_regex"].Status != "enabled" {
|
||||
t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status)
|
||||
}
|
||||
if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" {
|
||||
t.Fatalf(
|
||||
"tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex",
|
||||
gotTools["tool_search_tool_regex"].ConfigKey,
|
||||
)
|
||||
}
|
||||
if gotTools["tool_search_tool_bm25"].Status != "disabled" {
|
||||
t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status)
|
||||
}
|
||||
if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" {
|
||||
t.Fatalf(
|
||||
"tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25",
|
||||
gotTools["tool_search_tool_bm25"].ConfigKey,
|
||||
)
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if gotTools["i2c"].Status != "disabled" {
|
||||
t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status)
|
||||
}
|
||||
} else {
|
||||
cfg.Tools.I2C.Enabled = true
|
||||
cfg.Tools.SPI.Enabled = true
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/tools", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
gotTools = make(map[string]toolSupportItem, len(resp.Tools))
|
||||
for _, tool := range resp.Tools {
|
||||
gotTools[tool.Name] = tool
|
||||
}
|
||||
|
||||
if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" {
|
||||
t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"])
|
||||
}
|
||||
if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" {
|
||||
t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateToolState(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.Tools.Spawn.Enabled = false
|
||||
cfg.Tools.Subagent.Enabled = false
|
||||
cfg.Tools.Cron.Enabled = false
|
||||
cfg.Tools.MCP.Enabled = false
|
||||
cfg.Tools.MCP.Discovery.Enabled = false
|
||||
cfg.Tools.MCP.Discovery.UseRegex = false
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/tools/spawn/state",
|
||||
bytes.NewBufferString(`{"enabled":true}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/tools/tool_search_tool_regex/state",
|
||||
bytes.NewBufferString(`{"enabled":true}`),
|
||||
)
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String())
|
||||
}
|
||||
|
||||
rec3 := httptest.NewRecorder()
|
||||
req3 := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/tools/cron/state",
|
||||
bytes.NewBufferString(`{"enabled":true}`),
|
||||
)
|
||||
req3.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec3, req3)
|
||||
if rec3.Code != http.StatusOK {
|
||||
t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String())
|
||||
}
|
||||
|
||||
updated, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig(updated) error = %v", err)
|
||||
}
|
||||
if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled {
|
||||
t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools)
|
||||
}
|
||||
if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex {
|
||||
t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP)
|
||||
}
|
||||
if !updated.Tools.Cron.Enabled {
|
||||
t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron)
|
||||
}
|
||||
}
|
||||
79
web/frontend/src/api/skills.ts
Normal file
79
web/frontend/src/api/skills.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export interface SkillSupportItem {
|
||||
name: string
|
||||
path: string
|
||||
source: "workspace" | "global" | "builtin" | string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface SkillDetailResponse extends SkillSupportItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SkillsResponse {
|
||||
skills: SkillSupportItem[]
|
||||
}
|
||||
|
||||
interface SkillActionResponse {
|
||||
status?: string
|
||||
name?: string
|
||||
path?: string
|
||||
source?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractErrorMessage(res))
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getSkills(): Promise<SkillsResponse> {
|
||||
return request<SkillsResponse>("/api/skills")
|
||||
}
|
||||
|
||||
export async function getSkill(name: string): Promise<SkillDetailResponse> {
|
||||
return request<SkillDetailResponse>(`/api/skills/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export async function importSkill(file: File): Promise<SkillActionResponse> {
|
||||
const formData = new FormData()
|
||||
formData.set("file", file)
|
||||
|
||||
const res = await fetch("/api/skills/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractErrorMessage(res))
|
||||
}
|
||||
return res.json() as Promise<SkillActionResponse>
|
||||
}
|
||||
|
||||
export async function deleteSkill(name: string): Promise<SkillActionResponse> {
|
||||
return request<SkillActionResponse>(
|
||||
`/api/skills/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function extractErrorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.json()) 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 {
|
||||
// ignore invalid body
|
||||
}
|
||||
return `API error: ${res.status} ${res.statusText}`
|
||||
}
|
||||
56
web/frontend/src/api/tools.ts
Normal file
56
web/frontend/src/api/tools.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export interface ToolSupportItem {
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
config_key: string
|
||||
status: "enabled" | "disabled" | "blocked"
|
||||
reason_code?: string
|
||||
}
|
||||
|
||||
interface ToolsResponse {
|
||||
tools: ToolSupportItem[]
|
||||
}
|
||||
|
||||
interface ToolActionResponse {
|
||||
status: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, options)
|
||||
if (!res.ok) {
|
||||
let message = `API error: ${res.status} ${res.statusText}`
|
||||
try {
|
||||
const body = (await res.json()) as {
|
||||
error?: string
|
||||
errors?: string[]
|
||||
}
|
||||
if (Array.isArray(body.errors) && body.errors.length > 0) {
|
||||
message = body.errors.join("; ")
|
||||
} else if (typeof body.error === "string" && body.error.trim() !== "") {
|
||||
message = body.error
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid body
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function getTools(): Promise<ToolsResponse> {
|
||||
return request<ToolsResponse>("/api/tools")
|
||||
}
|
||||
|
||||
export async function setToolEnabled(
|
||||
name: string,
|
||||
enabled: boolean,
|
||||
): Promise<ToolActionResponse> {
|
||||
return request<ToolActionResponse>(
|
||||
`/api/tools/${encodeURIComponent(name)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
IconSettings,
|
||||
IconSparkles,
|
||||
IconTools,
|
||||
} from "@tabler/icons-react"
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
|
|
@ -53,6 +55,10 @@ const baseNavGroups: Omit<NavGroup, "items">[] = [
|
|||
label: "navigation.model_group",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.agent_group",
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
label: "navigation.services",
|
||||
defaultOpen: true,
|
||||
|
|
@ -113,6 +119,23 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
},
|
||||
{
|
||||
...baseNavGroups[2],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.skills",
|
||||
url: "/agent/skills",
|
||||
icon: IconSparkles,
|
||||
translateTitle: true,
|
||||
},
|
||||
{
|
||||
title: "navigation.tools",
|
||||
url: "/agent/tools",
|
||||
icon: IconTools,
|
||||
translateTitle: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseNavGroups[3],
|
||||
items: [
|
||||
{
|
||||
title: "navigation.config",
|
||||
|
|
|
|||
314
web/frontend/src/components/skills/skills-page.tsx
Normal file
314
web/frontend/src/components/skills/skills-page.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
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 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 bg-white/80"
|
||||
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/60 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]}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
190
web/frontend/src/components/tools/tools-page.tsx
Normal file
190
web/frontend/src/components/tools/tools-page.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
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"
|
||||
|
||||
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"] })
|
||||
},
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ import {
|
|||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||
import { gatewayAtom } from "@/store/gateway"
|
||||
|
||||
const DEFAULT_VISIBLE_CHANNELS = 5
|
||||
const DEFAULT_VISIBLE_CHANNELS = 4
|
||||
const CHANNEL_IMPORTANCE_ORDER = [
|
||||
"discord",
|
||||
"feishu",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"model_group": "Models",
|
||||
"models": "Models",
|
||||
"credentials": "Credentials",
|
||||
"agent_group": "Agent",
|
||||
"skills": "Skills",
|
||||
"tools": "Tools",
|
||||
"services": "Services",
|
||||
"channels_group": "Channels",
|
||||
"show_more_channels": "More",
|
||||
|
|
@ -326,6 +329,100 @@
|
|||
}
|
||||
},
|
||||
"pages": {
|
||||
"agent": {
|
||||
"load_error": "Failed to load agent support information.",
|
||||
"stats": {
|
||||
"workspace": "Workspace",
|
||||
"workspace_hint": "The default agent workspace used for runtime files and workspace skills.",
|
||||
"skills": "Available Skills",
|
||||
"skills_hint": "Skills discovered from workspace, global, and builtin roots.",
|
||||
"tools": "Enabled Tools",
|
||||
"tools_hint": "{{blocked}} blocked by missing dependencies."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
"description": "Skills are loaded from the workspace, global PicoClaw home, and builtin directories.",
|
||||
"hero_title": "Skill Library",
|
||||
"hero_description": "Browse every capability package the agent can load, then drill straight into the effective SKILL.md without leaving the page.",
|
||||
"stats": {
|
||||
"total": "Total Skills",
|
||||
"workspace": "Workspace",
|
||||
"shared": "Shared"
|
||||
},
|
||||
"empty": "No skills are currently available.",
|
||||
"import": "Import Skill",
|
||||
"import_title": "Import Skill",
|
||||
"import_description": "Create a workspace skill by uploading a markdown file as the new SKILL.md.",
|
||||
"import_name": "Skill Name",
|
||||
"import_name_placeholder": "e.g. my-workflow",
|
||||
"import_file": "Markdown File",
|
||||
"import_file_hint": "Upload a .md file. The backend stores it as workspace/skills/<name>/SKILL.md.",
|
||||
"import_confirm": "Import Skill",
|
||||
"import_success": "Skill imported.",
|
||||
"import_error": "Failed to import skill.",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"delete_title": "Delete Skill?",
|
||||
"delete_description": "\"{{name}}\" will be removed from workspace skills.",
|
||||
"delete_confirm": "Delete",
|
||||
"delete_success": "Skill deleted.",
|
||||
"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.",
|
||||
"source": "Source",
|
||||
"path": "Skill Path",
|
||||
"no_description": "No description provided.",
|
||||
"sources": {
|
||||
"workspace": "Workspace",
|
||||
"global": "Global",
|
||||
"builtin": "Builtin"
|
||||
},
|
||||
"errors": {
|
||||
"file_required": "Please choose a markdown file to import."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Tools",
|
||||
"description": "This view reflects whether each agent tool is enabled, disabled, or blocked by a missing prerequisite.",
|
||||
"hero_title": "Tool Surface",
|
||||
"hero_description": "Inspect what the agent can actually call right now, which capabilities are blocked, and where each tool is controlled in config.",
|
||||
"stats": {
|
||||
"enabled": "Enabled",
|
||||
"blocked": "Blocked",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"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",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"categories": {
|
||||
"automation": "Automation",
|
||||
"filesystem": "Filesystem",
|
||||
"web": "Web",
|
||||
"communication": "Communication",
|
||||
"skills": "Skills",
|
||||
"agents": "Agents",
|
||||
"hardware": "Hardware",
|
||||
"discovery": "Discovery"
|
||||
},
|
||||
"reasons": {
|
||||
"requires_linux": "This tool only works on Linux hosts with the required device files exposed.",
|
||||
"requires_skills": "Enable `tools.skills` before this skill-registry tool can be used.",
|
||||
"requires_subagent": "Enable `tools.subagent` before the spawn tool can delegate work.",
|
||||
"requires_mcp_discovery": "Enable `tools.mcp.discovery` before MCP discovery tools become available."
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"load_error": "Failed to load configuration. Please refresh and try again.",
|
||||
"workspace": "Workspace Directory",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"model_group": "模型",
|
||||
"models": "模型",
|
||||
"credentials": "凭据",
|
||||
"agent_group": "智能体",
|
||||
"skills": "技能",
|
||||
"tools": "工具",
|
||||
"services": "服务",
|
||||
"channels_group": "频道",
|
||||
"show_more_channels": "更多",
|
||||
|
|
@ -326,6 +329,100 @@
|
|||
}
|
||||
},
|
||||
"pages": {
|
||||
"agent": {
|
||||
"load_error": "加载 Agent 支持信息失败。",
|
||||
"stats": {
|
||||
"workspace": "工作目录",
|
||||
"workspace_hint": "默认 Agent 运行时使用的工作目录,也用于加载工作区技能。",
|
||||
"skills": "可用技能数",
|
||||
"skills_hint": "从工作区、全局目录和内置目录发现的技能。",
|
||||
"tools": "已启用工具",
|
||||
"tools_hint": "其中 {{blocked}} 个因依赖未满足而不可用。"
|
||||
},
|
||||
"skills": {
|
||||
"title": "技能",
|
||||
"description": "技能会从工作区、PicoClaw 全局目录和内置目录中加载。",
|
||||
"hero_title": "技能库",
|
||||
"hero_description": "在这里查看 Agent 当前可加载的能力包,并且不离开页面就能直接阅读生效后的 SKILL.md。",
|
||||
"stats": {
|
||||
"total": "技能总数",
|
||||
"workspace": "工作区技能",
|
||||
"shared": "共享技能"
|
||||
},
|
||||
"empty": "当前没有可用技能。",
|
||||
"import": "导入技能",
|
||||
"import_title": "导入技能",
|
||||
"import_description": "通过上传 Markdown 文件创建工作区技能,文件会保存为新的 SKILL.md。",
|
||||
"import_name": "技能名称",
|
||||
"import_name_placeholder": "例如 my-workflow",
|
||||
"import_file": "Markdown 文件",
|
||||
"import_file_hint": "上传一个 .md 文件。后端会保存到 workspace/skills/<name>/SKILL.md。",
|
||||
"import_confirm": "导入技能",
|
||||
"import_success": "技能导入成功。",
|
||||
"import_error": "导入技能失败。",
|
||||
"view": "查看",
|
||||
"delete": "删除",
|
||||
"delete_title": "删除技能?",
|
||||
"delete_description": "将从工作区技能中移除「{{name}}」。",
|
||||
"delete_confirm": "删除",
|
||||
"delete_success": "技能已删除。",
|
||||
"delete_error": "删除技能失败。",
|
||||
"viewer_title": "技能内容",
|
||||
"viewer_description": "这里展示当前生效的 SKILL.md 内容。",
|
||||
"loading_detail": "正在加载技能内容...",
|
||||
"load_detail_error": "加载技能内容失败。",
|
||||
"source": "来源",
|
||||
"path": "技能路径",
|
||||
"no_description": "未提供描述。",
|
||||
"sources": {
|
||||
"workspace": "工作区",
|
||||
"global": "全局",
|
||||
"builtin": "内置"
|
||||
},
|
||||
"errors": {
|
||||
"file_required": "请先选择要导入的 Markdown 文件。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "工具",
|
||||
"description": "这里展示每个 Agent 工具当前是已启用、已禁用,还是被依赖条件阻塞。",
|
||||
"hero_title": "工具面板",
|
||||
"hero_description": "集中查看 Agent 现在真正可调用的工具、被阻塞的能力,以及它们分别受哪项配置控制。",
|
||||
"stats": {
|
||||
"enabled": "已启用",
|
||||
"blocked": "被阻塞",
|
||||
"categories": "分类数"
|
||||
},
|
||||
"empty": "当前没有可用工具。",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"enable_success": "工具已启用。",
|
||||
"disable_success": "工具已禁用。",
|
||||
"toggle_error": "更新工具状态失败。",
|
||||
"config_key": "由 tools.{{key}} 控制",
|
||||
"status": {
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"blocked": "被阻塞"
|
||||
},
|
||||
"categories": {
|
||||
"automation": "自动化",
|
||||
"filesystem": "文件系统",
|
||||
"web": "网页",
|
||||
"communication": "通信",
|
||||
"skills": "技能",
|
||||
"agents": "Agent",
|
||||
"hardware": "硬件",
|
||||
"discovery": "发现"
|
||||
},
|
||||
"reasons": {
|
||||
"requires_linux": "该工具仅在 Linux 主机上可用,并且需要暴露对应的设备文件。",
|
||||
"requires_skills": "需要先启用 `tools.skills`,该技能注册表工具才能使用。",
|
||||
"requires_subagent": "需要先启用 `tools.subagent`,`spawn` 才能委派任务。",
|
||||
"requires_mcp_discovery": "需要先启用 `tools.mcp.discovery`,MCP 发现工具才会可用。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"load_error": "加载配置失败,请刷新后重试。",
|
||||
"workspace": "工作目录",
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@ import { Route as ModelsRouteImport } from './routes/models'
|
|||
import { Route as LogsRouteImport } from './routes/logs'
|
||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||
import { Route as ConfigRouteImport } from './routes/config'
|
||||
import { Route as AgentRouteImport } from './routes/agent'
|
||||
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
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'
|
||||
|
||||
const ModelsRoute = ModelsRouteImport.update({
|
||||
id: '/models',
|
||||
|
|
@ -38,6 +41,11 @@ const ConfigRoute = ConfigRouteImport.update({
|
|||
path: '/config',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentRoute = AgentRouteImport.update({
|
||||
id: '/agent',
|
||||
path: '/agent',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ChannelsRouteRoute = ChannelsRouteRouteImport.update({
|
||||
id: '/channels',
|
||||
path: '/channels',
|
||||
|
|
@ -58,24 +66,40 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
|||
path: '/$name',
|
||||
getParentRoute: () => ChannelsRouteRoute,
|
||||
} as any)
|
||||
const AgentToolsRoute = AgentToolsRouteImport.update({
|
||||
id: '/tools',
|
||||
path: '/tools',
|
||||
getParentRoute: () => AgentRoute,
|
||||
} as any)
|
||||
const AgentSkillsRoute = AgentSkillsRouteImport.update({
|
||||
id: '/skills',
|
||||
path: '/skills',
|
||||
getParentRoute: () => AgentRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/agent': typeof AgentRouteWithChildren
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
'/agent/tools': typeof AgentToolsRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/agent': typeof AgentRouteWithChildren
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
'/agent/tools': typeof AgentToolsRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
}
|
||||
|
|
@ -83,10 +107,13 @@ export interface FileRoutesById {
|
|||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/channels': typeof ChannelsRouteRouteWithChildren
|
||||
'/agent': typeof AgentRouteWithChildren
|
||||
'/config': typeof ConfigRouteWithChildren
|
||||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
'/agent/tools': typeof AgentToolsRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
}
|
||||
|
|
@ -95,30 +122,39 @@ export interface FileRouteTypes {
|
|||
fullPaths:
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/agent'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/agent/skills'
|
||||
| '/agent/tools'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/agent'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/agent/skills'
|
||||
| '/agent/tools'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/channels'
|
||||
| '/agent'
|
||||
| '/config'
|
||||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/agent/skills'
|
||||
| '/agent/tools'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
fileRoutesById: FileRoutesById
|
||||
|
|
@ -126,6 +162,7 @@ export interface FileRouteTypes {
|
|||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ChannelsRouteRoute: typeof ChannelsRouteRouteWithChildren
|
||||
AgentRoute: typeof AgentRouteWithChildren
|
||||
ConfigRoute: typeof ConfigRouteWithChildren
|
||||
CredentialsRoute: typeof CredentialsRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
|
|
@ -162,6 +199,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ConfigRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agent': {
|
||||
id: '/agent'
|
||||
path: '/agent'
|
||||
fullPath: '/agent'
|
||||
preLoaderRoute: typeof AgentRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/channels': {
|
||||
id: '/channels'
|
||||
path: '/channels'
|
||||
|
|
@ -190,6 +234,20 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ChannelsNameRouteImport
|
||||
parentRoute: typeof ChannelsRouteRoute
|
||||
}
|
||||
'/agent/tools': {
|
||||
id: '/agent/tools'
|
||||
path: '/tools'
|
||||
fullPath: '/agent/tools'
|
||||
preLoaderRoute: typeof AgentToolsRouteImport
|
||||
parentRoute: typeof AgentRoute
|
||||
}
|
||||
'/agent/skills': {
|
||||
id: '/agent/skills'
|
||||
path: '/skills'
|
||||
fullPath: '/agent/skills'
|
||||
preLoaderRoute: typeof AgentSkillsRouteImport
|
||||
parentRoute: typeof AgentRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +263,18 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
|||
ChannelsRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface AgentRouteChildren {
|
||||
AgentSkillsRoute: typeof AgentSkillsRoute
|
||||
AgentToolsRoute: typeof AgentToolsRoute
|
||||
}
|
||||
|
||||
const AgentRouteChildren: AgentRouteChildren = {
|
||||
AgentSkillsRoute: AgentSkillsRoute,
|
||||
AgentToolsRoute: AgentToolsRoute,
|
||||
}
|
||||
|
||||
const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren)
|
||||
|
||||
interface ConfigRouteChildren {
|
||||
ConfigRawRoute: typeof ConfigRawRoute
|
||||
}
|
||||
|
|
@ -219,6 +289,7 @@ const ConfigRouteWithChildren =
|
|||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||
AgentRoute: AgentRouteWithChildren,
|
||||
ConfigRoute: ConfigRouteWithChildren,
|
||||
CredentialsRoute: CredentialsRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
|
|
|
|||
22
web/frontend/src/routes/agent.tsx
Normal file
22
web/frontend/src/routes/agent.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
createFileRoute,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/agent")({
|
||||
component: AgentLayout,
|
||||
})
|
||||
|
||||
function AgentLayout() {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
|
||||
if (pathname === "/agent") {
|
||||
return <Navigate to="/agent/skills" />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
11
web/frontend/src/routes/agent/skills.tsx
Normal file
11
web/frontend/src/routes/agent/skills.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { SkillsPage } from "@/components/skills/skills-page"
|
||||
|
||||
export const Route = createFileRoute("/agent/skills")({
|
||||
component: AgentSkillsRoute,
|
||||
})
|
||||
|
||||
function AgentSkillsRoute() {
|
||||
return <SkillsPage />
|
||||
}
|
||||
11
web/frontend/src/routes/agent/tools.tsx
Normal file
11
web/frontend/src/routes/agent/tools.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import { ToolsPage } from "@/components/tools/tools-page"
|
||||
|
||||
export const Route = createFileRoute("/agent/tools")({
|
||||
component: AgentToolsRoute,
|
||||
})
|
||||
|
||||
function AgentToolsRoute() {
|
||||
return <ToolsPage />
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue