From 07d6b65729cf4549a02a05bfc75fb3a6f2920087 Mon Sep 17 00:00:00 2001 From: Sunny Date: Thu, 5 Nov 2026 16:46:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E6=8A=80=E8=83=BD=E8=A7=84?= =?UTF-8?q?=E5=88=99=20-=20=E6=94=BE=E5=BC=80=E5=A4=A7=E5=B0=8F=E5=92=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=95=B0=E9=99=90=E5=88=B6=EF=BC=8C=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E5=B7=A5=E5=85=B7=E8=A3=85=E7=9A=84=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=BF=9B=E6=8A=80=E8=83=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 简介上限改成 1024 字,超长截断;整包限制 200MB,不再限制文件数和单文件大小 - 读取 ~/.agents/skills,标记「其他工具安装」,可以用开关关掉 - 带脚本的技能标「含脚本」 - 启动时不再重建插件和本机技能的包,重启不再丢文件 - 同步不再打开用户停用的技能 Co-Authored-By: Claude Opus 5.5 --- backend/internal/bootstrap/runtime.go | 1 + backend/internal/handler/skills.go | 30 +++ backend/internal/repository/skill_market.go | 18 ++ backend/internal/skills/agent_resources.go | 3 +- backend/internal/skills/local_skills.go | 109 ++++++++ backend/internal/skills/local_skills_test.go | 255 ++++++++++++++++++ backend/internal/skills/plugin_skills.go | 213 +++++++++------ backend/internal/skills/skill_archive.go | 30 +-- backend/internal/skills/skill_display_name.go | 2 +- backend/internal/skills/skill_github.go | 2 +- backend/internal/skills/skill_packages.go | 20 +- .../internal/skills/skill_packages_test.go | 2 +- backend/internal/skills/skills.go | 9 +- backend/internal/skills/skills_seed.go | 5 +- .../content/docs/backend/backend-database.mdx | 4 +- docs/content/docs/overview/features.mdx | 2 + web/src/pages/skills/local-skill-source.tsx | 48 ++++ web/src/pages/skills/marketplace.css | 2 +- web/src/pages/skills/marketplace.tsx | 21 +- web/src/pages/skills/skill-catalog.ts | 8 + web/src/pages/skills/skill-detail-drawer.tsx | 7 +- web/src/pages/skills/skill-editor-drawer.tsx | 4 +- web/src/pages/skills/skill-install-modal.tsx | 4 +- web/src/services/api/skills.ts | 17 +- 24 files changed, 674 insertions(+), 142 deletions(-) create mode 100644 backend/internal/skills/local_skills.go create mode 100644 backend/internal/skills/local_skills_test.go create mode 100644 web/src/pages/skills/local-skill-source.tsx diff --git a/backend/internal/bootstrap/runtime.go b/backend/internal/bootstrap/runtime.go index af5956649..6026bdb71 100644 --- a/backend/internal/bootstrap/runtime.go +++ b/backend/internal/bootstrap/runtime.go @@ -343,6 +343,7 @@ func initializeService(svc *app.Service) error { svc.EnsureDefaultPromptTemplates, svc.EnsureBuiltinProjectWorkflowTemplate, svc.EnsureBuiltinSkills, + svc.SkillsDomain().SyncLocalSkillsAtStartup, svc.EnsureSkillPackages, } for _, initialize := range initializers { diff --git a/backend/internal/handler/skills.go b/backend/internal/handler/skills.go index 91aa62acf..a300bd567 100644 --- a/backend/internal/handler/skills.go +++ b/backend/internal/handler/skills.go @@ -89,6 +89,36 @@ func registerSkillRoutes(r *gin.RouterGroup, owners workspaceOwners, skillsDomai }) } + r.GET("/skills/local-source", func(c *gin.Context) { + source, err := skillsDomain.LocalSkillSource() + if err != nil { + failService(c, err) + return + } + ok(c, source) + }) + r.PUT("/skills/local-source", func(c *gin.Context) { + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + source, err := skillsDomain.SetLocalSkillSource(req.Enabled) + if err != nil { + failService(c, err) + return + } + ok(c, source) + }) + r.POST("/skills/local-source/sync", func(c *gin.Context) { + if err := skillsDomain.SyncLocalSkills(); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"synced": true}) + }) r.GET("/skills", func(c *gin.Context) { user, err := currentUser(c, owners) if err != nil { diff --git a/backend/internal/repository/skill_market.go b/backend/internal/repository/skill_market.go index b449cadab..eddbd31f8 100644 --- a/backend/internal/repository/skill_market.go +++ b/backend/internal/repository/skill_market.go @@ -27,6 +27,24 @@ func (r *Repository) SkillVersionsByIDs(ids []string) ([]model.SkillVersion, err err := r.db.Where("id IN ?", ids).Find(&rows).Error return rows, err } + +// SkillScriptsPrefix 是技能标准里放可执行脚本的目录。 +const SkillScriptsPrefix = "scripts/" + +// SkillVersionsWithScripts 返回这些版本里带脚本的版本。 +func (r *Repository) SkillVersionsWithScripts(ids []string) (map[string]bool, error) { + result := make(map[string]bool, len(ids)) + if len(ids) == 0 { + return result, nil + } + var rows []string + err := r.db.Model(&model.SkillFile{}).Distinct("skill_version_id").Where("skill_version_id IN ? AND path LIKE ?", ids, SkillScriptsPrefix+"%").Pluck("skill_version_id", &rows).Error + for _, id := range rows { + result[id] = true + } + return result, err +} + func (r *Repository) SwitchSkillVersion(userID, skillID, versionID, instruction, name, description, sourceURL string) error { return r.db.Transaction(func(tx *gorm.DB) error { var s model.Skill diff --git a/backend/internal/skills/agent_resources.go b/backend/internal/skills/agent_resources.go index 1c33b2299..60bf7c7cb 100644 --- a/backend/internal/skills/agent_resources.go +++ b/backend/internal/skills/agent_resources.go @@ -12,6 +12,7 @@ import ( "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" ) // Pin is the exact installed package selected by the user for a business round. @@ -116,7 +117,7 @@ func (s *Service) AgentVersion(userID string, pin Pin) (*AgentResource, error) { result := &AgentResource{Pin: pin, Name: skill.Name, Description: skill.Description, Version: version.VersionLabel, Files: []SkillPackageFileItem{}, UnsupportedCapabilities: []string{}} for _, file := range files { result.Files = append(result.Files, skillFileItem(file)) - if strings.HasPrefix(file.Path, "scripts/") { + if strings.HasPrefix(file.Path, repository.SkillScriptsPrefix) { result.UnsupportedCapabilities = []string{"script_execution"} } } diff --git a/backend/internal/skills/local_skills.go b/backend/internal/skills/local_skills.go new file mode 100644 index 000000000..dd9dc0381 --- /dev/null +++ b/backend/internal/skills/local_skills.go @@ -0,0 +1,109 @@ +package skills + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + "strings" + + "infinite-canvas/backend/internal/model" +) + +const ( + localSkillsSettingKey = "local_skills_directory" + localSkillsSourceURL = "local://agents-skills" + localSkillsDirectory = "~/.agents/skills" +) + +// LocalSkillSource 是技能库读取本机技能目录的开关。其他 AI 工具把技能装在这个目录里,默认一起用。 +type LocalSkillSource struct { + Enabled bool `json:"enabled"` + Directory string `json:"directory"` +} + +func (s *Service) LocalSkillSource() (*LocalSkillSource, error) { + enabled, err := s.localSkillsEnabled() + if err != nil { + return nil, err + } + return &LocalSkillSource{Enabled: enabled, Directory: localSkillsDirectory}, nil +} + +// SetLocalSkillSource 保存开关并立刻同步:打开时读进来,关闭时把读进来的技能从技能库拿掉。 +// 只动技能库里的记录,不碰用户电脑上的目录。 +func (s *Service) SetLocalSkillSource(enabled bool) (*LocalSkillSource, error) { + value, err := json.Marshal(enabled) + if err != nil { + return nil, err + } + if err := s.repo.SaveSystemSetting(&model.SystemSetting{Key: localSkillsSettingKey, ValueJSON: string(value)}); err != nil { + return nil, err + } + if err := s.SyncLocalSkills(); err != nil { + return nil, err + } + return s.LocalSkillSource() +} + +func (s *Service) localSkillsEnabled() (bool, error) { + setting, err := s.repo.LookupSystemSetting(localSkillsSettingKey) + if err != nil || setting == nil { + return setting == nil, err + } + var enabled bool + if err := json.Unmarshal([]byte(setting.ValueJSON), &enabled); err != nil { + return false, err + } + return enabled, nil +} + +// SyncLocalSkills 让技能库和本机技能目录保持一致。启动时和打开技能库时各跑一次。 +func (s *Service) SyncLocalSkills() error { + if s == nil || s.repo == nil || s.repo.DB() == nil { + return nil + } + enabled, err := s.localSkillsEnabled() + if err != nil { + return err + } + var dirs []string + if enabled { + home, err := os.UserHomeDir() + if err != nil { + return errors.New("找不到本机用户目录") + } + dirs = localSkillDirs(filepath.Join(home, ".agents", "skills")) + } + return s.syncDirectorySkills(skillDirSource{ + sourceType: skillSourceTypeLocal, sourceURL: localSkillsSourceURL, idPrefix: "local-", ownerFallback: "local", + authorName: "本机", fallbackDescription: "本机技能", private: true, skipBroken: true, + }, dirs) +} + +// SyncLocalSkillsAtStartup 不让本机目录的问题挡住应用启动。 +func (s *Service) SyncLocalSkillsAtStartup() error { + if err := s.SyncLocalSkills(); err != nil { + log.Printf("读取本机技能失败:%v", err) + } + return nil +} + +// localSkillDirs 列出目录下的每个技能。技能管理工具常用软链接把技能放进来,这里跟着链接读真实目录。 +func localSkillDirs(root string) []string { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + dirs := make([]string, 0, len(entries)) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".") { + continue + } + if dir, err := filepath.EvalSymlinks(filepath.Join(root, entry.Name())); err == nil { + dirs = append(dirs, dir) + } + } + return dirs +} diff --git a/backend/internal/skills/local_skills_test.go b/backend/internal/skills/local_skills_test.go new file mode 100644 index 000000000..a549b7d41 --- /dev/null +++ b/backend/internal/skills/local_skills_test.go @@ -0,0 +1,255 @@ +package skills + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" +) + +func writeLocalSkill(t *testing.T, dir string, files map[string]string) { + t.Helper() + for name, content := range files { + target := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func localSkillFixture(t *testing.T) *Service { + t.Helper() + s, db := agentSkillFixture(t) + if err := db.AutoMigrate(&model.Workspace{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Workspace{ID: "workspace", Name: "本地工作区"}).Error; err != nil { + t.Fatal(err) + } + return s +} + +func localSkillIDs(t *testing.T, s *Service) []string { + t.Helper() + var rows []model.Skill + if err := s.repo.DB().Where("source_type = ?", "local").Order("id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + return ids +} + +func TestLocalSkillsFollowTheDirectoryAndTheSwitch(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + root := filepath.Join(home, ".agents", "skills") + writeLocalSkill(t, filepath.Join(root, "storyboard"), map[string]string{ + "SKILL.md": "---\nname: storyboard\ndescription: 拆分镜\n---\n正文", + "scripts/run.py": "print('ok')", + }) + writeLocalSkill(t, filepath.Join(root, ".cache"), map[string]string{"SKILL.md": "---\nname: hidden\ndescription: 隐藏\n---\n正文"}) + linked := filepath.Join(t.TempDir(), "voice-over") + writeLocalSkill(t, linked, map[string]string{"SKILL.md": "---\nname: voice-over\ndescription: 配音\n---\n正文"}) + if err := os.Symlink(linked, filepath.Join(root, "voice-over")); err != nil { + t.Fatal(err) + } + + s := localSkillFixture(t) + source, err := s.LocalSkillSource() + if err != nil || !source.Enabled || source.Directory != "~/.agents/skills" { + t.Fatalf("local skills should be on by default: %+v %v", source, err) + } + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + if ids := localSkillIDs(t, s); strings.Join(ids, ",") != "local-storyboard,local-voice-over" { + t.Fatalf("skills = %v", ids) + } + if skill, err := s.repo.Skill("local-storyboard"); err != nil || !skill.IsPrivate { + t.Fatalf("本机技能不算公开内容: %+v %v", skill, err) + } + + row, err := s.repo.Skill("local-storyboard") + if err != nil { + t.Fatal(err) + } + items, err := s.skillItems("workspace", []model.Skill{*row}, false) + if err != nil || !items[0].HasScripts || items[0].SourceType != "local" || !items[0].IsAdded { + t.Fatalf("item = %+v %v", items, err) + } + + writeLocalSkill(t, filepath.Join(root, "storyboard"), map[string]string{"SKILL.md": "---\nname: storyboard\ndescription: 新的说明\n---\n正文"}) + if err := os.RemoveAll(filepath.Join(root, "voice-over")); err != nil { + t.Fatal(err) + } + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + if row, err = s.repo.Skill("local-storyboard"); err != nil || row.Description != "新的说明" { + t.Fatalf("edits on disk should reach the library: %+v %v", row, err) + } + if ids := localSkillIDs(t, s); len(ids) != 1 { + t.Fatalf("a skill removed from disk should leave the library: %v", ids) + } + + if _, err := s.SetLocalSkillSource(false); err != nil { + t.Fatal(err) + } + if ids := localSkillIDs(t, s); len(ids) != 0 { + t.Fatalf("switching off should empty the library: %v", ids) + } + if _, err := os.Stat(filepath.Join(root, "storyboard", "SKILL.md")); err != nil { + t.Fatalf("switching off must not touch the user's files: %v", err) + } + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + if ids := localSkillIDs(t, s); len(ids) != 0 { + t.Fatalf("sync must respect the switch: %v", ids) + } +} + +func TestDirectorySkillsKeepTheirFilesAcrossRestart(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + files := map[string]string{"SKILL.md": "---\nname: trim\ndescription: 裁视频\n---\n正文", "scripts/trim.sh": "ffmpeg"} + writeLocalSkill(t, filepath.Join(home, ".agents", "skills", "trim"), files) + pluginDir := filepath.Join(t.TempDir(), "skills", "cutter") + writeLocalSkill(t, pluginDir, files) + + s := localSkillFixture(t) + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + if err := s.SyncPluginSkills("beeftv-plugin-cutter", "剪辑插件", "1.2.0", []string{pluginDir}); err != nil { + t.Fatal(err) + } + // 每次启动都会跑这一步,它不能动这两类技能。 + if err := s.EnsureSkillPackages(); err != nil { + t.Fatal(err) + } + for id, sourceType := range map[string]string{"local-trim": "local", "plugin-beeftv-plugin-cutter-cutter": "plugin"} { + skill, err := s.repo.Skill(id) + if err != nil { + t.Fatal(id, err) + } + if skill.SourceType != sourceType || skill.FileCount != 2 { + t.Fatalf("%s 启动后变成 sourceType=%s fileCount=%d", id, skill.SourceType, skill.FileCount) + } + var versions int64 + if err := s.repo.DB().Model(&model.SkillVersion{}).Where("skill_id = ?", id).Count(&versions).Error; err != nil { + t.Fatal(err) + } + if versions != 1 { + t.Fatalf("%s 启动后有 %d 个版本", id, versions) + } + } +} + +func TestSyncKeepsADisabledLocalSkillDisabled(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".agents", "skills", "trim") + writeLocalSkill(t, dir, map[string]string{"SKILL.md": "---\nname: trim\ndescription: 裁视频\n---\n正文"}) + + s := localSkillFixture(t) + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + if _, err := s.SetSkillAdded("workspace", "local-trim", false); err != nil { + t.Fatal(err) + } + writeLocalSkill(t, dir, map[string]string{"SKILL.md": "---\nname: trim\ndescription: 裁视频\n---\n改过的正文"}) + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + state, err := s.repo.UserSkillState("workspace", "local-trim") + if err != nil || state == nil { + t.Fatalf("state = %v, err = %v", state, err) + } + if state.Added { + t.Fatal("用户停用过的本机技能被同步重新打开了") + } + skill, err := s.repo.Skill("local-trim") + if err != nil { + t.Fatal(err) + } + if state.InstalledVersionID != skill.CurrentVersionID { + t.Fatal("同步后版本没有跟上目录里的内容") + } +} + +func TestLocalSkillThatCannotBeReadOnlySkipsItself(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root reads every file") + } + home := t.TempDir() + t.Setenv("HOME", home) + root := filepath.Join(home, ".agents", "skills") + writeLocalSkill(t, filepath.Join(root, "good"), map[string]string{"SKILL.md": "---\nname: good\ndescription: 好\n---\n正文"}) + writeLocalSkill(t, filepath.Join(root, "locked"), map[string]string{"SKILL.md": "---\nname: locked\ndescription: 读不了\n---\n正文", "references/note.md": "x"}) + if err := os.Chmod(filepath.Join(root, "locked", "references", "note.md"), 0); err != nil { + t.Fatal(err) + } + s := localSkillFixture(t) + if err := s.SyncLocalSkills(); err != nil { + t.Fatal(err) + } + if ids := localSkillIDs(t, s); strings.Join(ids, ",") != "local-good" { + t.Fatalf("skills = %v", ids) + } +} + +func zipSkill(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, content := range files { + entry, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(content); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestSkillPackageOnlyLimitsTotalSize(t *testing.T) { + description := strings.Repeat("述", maxSkillDescriptionRunes) + files := map[string][]byte{ + "SKILL.md": []byte("---\nname: big\ndescription: " + description + "\n---\n正文"), + "assets/big.md": bytes.Repeat([]byte("a"), 9<<20), + } + for index := 0; index < 600; index++ { + files[fmt.Sprintf("references/%d.md", index)] = []byte("x") + } + archive, err := archiveFromZip(zipSkill(t, files), "") + if err != nil { + t.Fatalf("a large file and many files are allowed: %v", err) + } + if utf8.RuneCountInString(archive.Metadata.Description) != maxSkillDescriptionRunes || archive.Metadata.Description != description { + t.Fatalf("a description within the standard's limit must stay whole: %d", utf8.RuneCountInString(archive.Metadata.Description)) + } + + oversized := map[string][]byte{"SKILL.md": files["SKILL.md"], "assets/huge.md": make([]byte, maxSkillPackageBytes+1)} + if _, err := archiveFromZip(zipSkill(t, oversized), ""); err == nil || !strings.Contains(err.Error(), "200MB") { + t.Fatalf("expected the total limit, got %v", err) + } +} diff --git a/backend/internal/skills/plugin_skills.go b/backend/internal/skills/plugin_skills.go index 0af77d6d2..2cb20f3e4 100644 --- a/backend/internal/skills/plugin_skills.go +++ b/backend/internal/skills/plugin_skills.go @@ -38,7 +38,38 @@ func (s *Service) SyncPluginSkills(pluginID string, pluginName string, version s if pluginID == "" { return nil } - sourceURL := "plugin://" + pluginID + authorName := strings.TrimSpace(pluginName) + if authorName == "" { + authorName = pluginID + } + return s.syncDirectorySkills(skillDirSource{ + sourceType: skillSourceTypePlugin, sourceURL: "plugin://" + pluginID, idPrefix: "plugin-" + sanitizePluginSkillSlug(pluginID) + "-", + ownerFallback: "plugin:" + pluginID, authorName: authorName, version: version, fallbackDescription: authorName + " 插件技能", + }, skillDirs) +} + +const ( + skillSourceTypePlugin = "plugin" + skillSourceTypeLocal = "local" +) + +// skillDirSource 说明一批文件夹技能从哪来。插件包和本机技能目录走同一条登记路径,只有这些字段不同。 +type skillDirSource struct { + sourceType string + sourceURL string + idPrefix string + ownerFallback string + authorName string + version string + fallbackDescription string + // private 表示技能只在这台电脑上,不算公开内容。 + private bool + // skipBroken 让读不出来的技能只跳过它自己。本机目录里的内容不归 BeefTV 管,一个坏技能不能拖累别的。 + skipBroken bool +} + +// syncDirectorySkills 把文件夹里的技能登记进技能库,并删掉这个来源下已经不存在的技能。 +func (s *Service) syncDirectorySkills(source skillDirSource, skillDirs []string) error { discovered, err := discoverPluginSkillDirectories(skillDirs) if err != nil { return err @@ -47,104 +78,114 @@ func (s *Service) SyncPluginSkills(pluginID string, pluginName string, version s if ws, wsErr := s.repo.DefaultWorkspace(); wsErr == nil && ws != nil { userID = strings.TrimSpace(ws.ID) } - authorName := strings.TrimSpace(pluginName) - if authorName == "" { - authorName = pluginID - } seenIDs := make(map[string]struct{}, len(discovered)) for _, entry := range discovered { - files, err := readSkillDirectoryFiles(entry.dir) - if err != nil { - return err - } - meta := parseSkillPackageMetadata(files["SKILL.md"]) - if meta.Name == "" { - meta.Name = entry.slug - } - if meta.Description == "" { - meta.Description = authorName + " 插件技能" - } - if strings.TrimSpace(version) != "" { - meta.Version = strings.TrimSpace(version) - } - archive, err := finalizeSkillArchive(files, meta) - if err != nil { - return err - } - skillID := "plugin-" + sanitizePluginSkillSlug(pluginID) + "-" + sanitizePluginSkillSlug(entry.slug) + skillID := source.idPrefix + sanitizePluginSkillSlug(entry.slug) seenIDs[skillID] = struct{}{} - versionLabel := archive.Metadata.Version - if versionLabel == "" { - versionLabel = "1.0.0" - } - existing, err := s.repo.Skill(skillID) - if errors.Is(err, gorm.ErrRecordNotFound) { - versionID := kernel.NewID() - packageKey, versionModel, fileModels, err := s.persistSkillArchive(skillID, versionID, archive, "") - if err != nil { + if err := s.syncDirectorySkill(source, userID, skillID, entry); err != nil { + if !source.skipBroken { return err } - versionModel.VersionLabel = versionLabel - now := time.Now() - ownerID := userID - if ownerID == "" { - ownerID = "plugin:" + pluginID - } - skill := &model.Skill{ - ID: skillID, OwnerID: ownerID, AuthorName: authorName, Name: archive.Metadata.Name, - Description: archive.Metadata.Description, Instruction: string(archive.Files["SKILL.md"]), - CurrentVersionID: versionID, VersionLabel: versionLabel, ContentHash: archive.ContentHash, - FileCount: len(archive.Files), TotalBytes: archive.TotalBytes, SourceType: "plugin", - SourceURL: sourceURL, SourceRef: versionLabel, SourceSubdir: entry.slug, SyncStatus: "synced", - LastCheckedAt: &now, LastSyncedAt: &now, Status: skillStatusEnabled, Source: 3, Tag: "others", - ShowcaseMediaJSON: "[]", - } - var state *model.UserSkillState - if userID != "" { - state = &model.UserSkillState{ID: kernel.NewID(), UserID: userID, SkillID: skillID, Added: true, InstalledVersionID: versionID} - } - if err := s.repo.CreateSkillWithPackage(skill, versionModel, fileModels, state); err != nil { - _ = os.Remove(filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey))) + log.Printf("跳过技能 %s:%v", entry.slug, err) + } + } + var existingRows []model.Skill + if err := s.repo.DB().Where("source_type = ? AND source_url = ?", source.sourceType, source.sourceURL).Find(&existingRows).Error; err != nil { + return err + } + for _, row := range existingRows { + if _, keep := seenIDs[row.ID]; !keep { + if err := s.repo.DeleteSkill(row.ID); err != nil { return err } - continue + _ = os.RemoveAll(filepath.Join(s.dataDir, "skill-packages", row.ID)) } + } + return nil +} + +func (s *Service) syncDirectorySkill(source skillDirSource, userID, skillID string, entry discoveredPluginSkillDir) error { + files, err := readSkillDirectoryFiles(entry.dir) + if err != nil { + return err + } + meta := parseSkillPackageMetadata(files["SKILL.md"]) + if meta.Name == "" { + meta.Name = entry.slug + } + if meta.Description == "" { + meta.Description = source.fallbackDescription + } + if strings.TrimSpace(source.version) != "" { + meta.Version = strings.TrimSpace(source.version) + } + archive, err := finalizeSkillArchive(files, meta) + if err != nil { + return err + } + versionLabel := archive.Metadata.Version + if versionLabel == "" { + versionLabel = "1.0.0" + } + existing, err := s.repo.Skill(skillID) + if errors.Is(err, gorm.ErrRecordNotFound) { + versionID := kernel.NewID() + packageKey, versionModel, fileModels, err := s.persistSkillArchive(skillID, versionID, archive, "") if err != nil { return err } - existing.Name, existing.Description = archive.Metadata.Name, archive.Metadata.Description - existing.Instruction, existing.AuthorName = string(archive.Files["SKILL.md"]), authorName - if existing.ContentHash != archive.ContentHash || existing.CurrentVersionID == "" { - if err := s.addSkillArchiveVersion(existing, archive, "plugin", sourceURL, versionLabel, entry.slug, "", false); err != nil { - return err - } - } else { - existing.VersionLabel, existing.SourceRef = versionLabel, versionLabel - if err := s.repo.SaveSkill(existing); err != nil { - return err - } + versionModel.VersionLabel = versionLabel + now := time.Now() + ownerID := userID + if ownerID == "" { + ownerID = source.ownerFallback + } + skill := &model.Skill{ + ID: skillID, OwnerID: ownerID, AuthorName: source.authorName, Name: archive.Metadata.Name, + Description: archive.Metadata.Description, Instruction: string(archive.Files["SKILL.md"]), + CurrentVersionID: versionID, VersionLabel: versionLabel, ContentHash: archive.ContentHash, + FileCount: len(archive.Files), TotalBytes: archive.TotalBytes, SourceType: source.sourceType, + SourceURL: source.sourceURL, SourceRef: versionLabel, SourceSubdir: entry.slug, SyncStatus: "synced", + LastCheckedAt: &now, LastSyncedAt: &now, Status: skillStatusEnabled, Source: 3, Tag: "others", + ShowcaseMediaJSON: "[]", IsPrivate: source.private, } + var state *model.UserSkillState if userID != "" { - state, err := s.skillState(userID, skillID) - if err != nil { - return err - } - state.Added, state.InstalledVersionID = true, existing.CurrentVersionID - if err := s.repo.SetUserSkillAdded(state); err != nil { - return err - } + state = &model.UserSkillState{ID: kernel.NewID(), UserID: userID, SkillID: skillID, Added: true, InstalledVersionID: versionID} } + if err := s.repo.CreateSkillWithPackage(skill, versionModel, fileModels, state); err != nil { + _ = os.Remove(filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey))) + return err + } + return nil } - var existingRows []model.Skill - if err := s.repo.DB().Where("source_type = ? AND source_url = ?", "plugin", sourceURL).Find(&existingRows).Error; err != nil { + if err != nil { return err } - for _, row := range existingRows { - if _, keep := seenIDs[row.ID]; !keep { - if err := s.repo.DeleteSkill(row.ID); err != nil { - return err - } - _ = os.RemoveAll(filepath.Join(s.dataDir, "skill-packages", row.ID)) + existing.Name, existing.Description = archive.Metadata.Name, archive.Metadata.Description + existing.Instruction, existing.AuthorName = string(archive.Files["SKILL.md"]), source.authorName + if existing.ContentHash != archive.ContentHash || existing.CurrentVersionID == "" { + if err := s.addSkillArchiveVersion(existing, archive, source.sourceType, source.sourceURL, versionLabel, entry.slug, "", false); err != nil { + return err + } + } else { + existing.VersionLabel, existing.SourceRef = versionLabel, versionLabel + if err := s.repo.SaveSkill(existing); err != nil { + return err + } + } + if userID != "" { + state, err := s.repo.UserSkillState(userID, skillID) + if err != nil { + return err + } + if state == nil { + state = &model.UserSkillState{ID: kernel.NewID(), UserID: userID, SkillID: skillID, Added: true} + } + // 同步只换版本。用户停用过的技能,不能被同步重新打开。 + state.InstalledVersionID = existing.CurrentVersionID + if err := s.repo.SetUserSkillAdded(state); err != nil { + return err } } return nil @@ -233,12 +274,9 @@ func readSkillDirectoryFiles(rootDir string) (map[string][]byte, error) { if err != nil { return err } - if len(content) > maxSkillFileBytes { - return kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } total += int64(len(content)) if total > maxSkillPackageBytes { - return kernel.BadAuthRequest("技能包解压后不能超过 20MB") + return errSkillPackageTooLarge() } files[normPath] = content return nil @@ -246,9 +284,6 @@ func readSkillDirectoryFiles(rootDir string) (map[string][]byte, error) { if err != nil { return nil, err } - if len(files) > maxSkillPackageFiles { - return nil, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") - } return files, nil } diff --git a/backend/internal/skills/skill_archive.go b/backend/internal/skills/skill_archive.go index 365b6692b..a03bcfcb1 100644 --- a/backend/internal/skills/skill_archive.go +++ b/backend/internal/skills/skill_archive.go @@ -29,7 +29,7 @@ func archiveFromMarkdown(data []byte, fallbackName string, fallbackDescription s } func archiveFromMarkdownWithFiles(data []byte, fallbackName string, fallbackDescription string, extraFiles map[string][]byte) (skillPackageArchive, error) { - if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) { + if len(data) == 0 || len(data) > maxSkillPackageBytes || !utf8.Valid(data) { return skillPackageArchive{}, kernel.BadAuthRequest("Markdown 文件为空、过大或不是 UTF-8") } metadata := parseSkillPackageMetadata(data) @@ -55,9 +55,6 @@ func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) { if err != nil { return skillPackageArchive{}, kernel.BadAuthRequest("ZIP 文件无法解析") } - if len(reader.File) > maxSkillPackageFiles+64 { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") - } raw := make(map[string][]byte) var total int64 for _, entry := range reader.File { @@ -77,14 +74,12 @@ func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) { if _, exists := raw[entryPath]; exists { return skillPackageArchive{}, kernel.BadAuthRequest("技能包包含重复文件路径") } - if entry.UncompressedSize64 > maxSkillFileBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } + // 声明的大小可能是假的,按实际读到的字节数算总量。 file, err := entry.Open() if err != nil { return skillPackageArchive{}, err } - content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + content, readErr := io.ReadAll(io.LimitReader(file, maxSkillPackageBytes-total+1)) closeErr := file.Close() if readErr != nil { return skillPackageArchive{}, readErr @@ -92,12 +87,9 @@ func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) { if closeErr != nil { return skillPackageArchive{}, closeErr } - if len(content) > maxSkillFileBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } total += int64(len(content)) if total > maxSkillPackageBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包解压后不能超过 20MB") + return skillPackageArchive{}, errSkillPackageTooLarge() } raw[entryPath] = content } @@ -178,9 +170,6 @@ func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string } files[filePath] = content } - if len(files) > maxSkillPackageFiles { - return nil, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") - } return files, nil } @@ -194,16 +183,17 @@ func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata if metadata.Description == "" { return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 description,且无法从正文推断") } - if utf8.RuneCountInString(metadata.Name) > maxSkillNameRunes || utf8.RuneCountInString(metadata.Description) > maxSkillDescriptionRunes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能名称或简介超过长度限制") + if utf8.RuneCountInString(metadata.Name) > maxSkillNameRunes { + return skillPackageArchive{}, kernel.BadAuthRequest("技能名称超过长度限制") } + metadata.Description = fitRunes(metadata.Description, maxSkillDescriptionRunes) hash := sha256.New() var total int64 for _, filePath := range sortedSkillPaths(files) { content := files[filePath] total += int64(len(content)) if total > maxSkillPackageBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包解压后不能超过 20MB") + return skillPackageArchive{}, errSkillPackageTooLarge() } hash.Write([]byte(filePath)) hash.Write([]byte{0}) @@ -300,6 +290,10 @@ func parseSkillPackageMetadata(data []byte) skillPackageMetadata { return metadata } +func errSkillPackageTooLarge() error { + return kernel.BadAuthRequest("技能包解压后不能超过 200MB") +} + // fitRunes 把超长文本截到上限以内(省略号也算在内),截完不会再被打包校验判为超长。 func fitRunes(value string, limit int) string { runes := []rune(value) diff --git a/backend/internal/skills/skill_display_name.go b/backend/internal/skills/skill_display_name.go index ed12ce4ea..71de0427e 100644 --- a/backend/internal/skills/skill_display_name.go +++ b/backend/internal/skills/skill_display_name.go @@ -15,7 +15,7 @@ import ( // 技能名称和简介的长度上限;解析截断、打包校验、显示名推断和手动改名共用。 const ( maxSkillNameRunes = 80 - maxSkillDescriptionRunes = 500 + maxSkillDescriptionRunes = 1024 ) // skillDisplayNamesRepairedKey 标记早先导入的技能已按中文标题修复过显示名;只修一次。 diff --git a/backend/internal/skills/skill_github.go b/backend/internal/skills/skill_github.go index 30e6fa4fc..c9e9538ef 100644 --- a/backend/internal/skills/skill_github.go +++ b/backend/internal/skills/skill_github.go @@ -78,7 +78,7 @@ func fetchGitHubSkillArchive(ctx context.Context, spec githubSkillSpec) (skillPa return skillPackageArchive{}, "", "", "", err } if len(data) > maxSkillPackageBytes { - return skillPackageArchive{}, "", "", "", kernel.BadAuthRequest("GitHub 技能包超过 20MB") + return skillPackageArchive{}, "", "", "", kernel.BadAuthRequest("GitHub 技能包超过 200MB") } archive, err := archiveFromZip(data, spec.Subdir) if err != nil { diff --git a/backend/internal/skills/skill_packages.go b/backend/internal/skills/skill_packages.go index f2eec84f7..9ed74a198 100644 --- a/backend/internal/skills/skill_packages.go +++ b/backend/internal/skills/skill_packages.go @@ -29,9 +29,8 @@ import ( ) const ( - maxSkillPackageBytes = 20 << 20 - maxSkillFileBytes = 8 << 20 - maxSkillPackageFiles = 512 + // 只防压缩炸弹,不限单个文件大小和文件数:技能标准没有这些限制。 + maxSkillPackageBytes = 200 << 20 maxSkillPreviewBytes = 512 << 10 SkillPackageUploadMaxBytes = maxSkillPackageBytes + (1 << 20) ) @@ -114,6 +113,10 @@ func (s *Service) EnsureSkillPackages() error { } for index := range skills { skill := &skills[index] + // 插件和本机技能的文件在各自的目录里,由同步负责。这里手上只有 SKILL.md,重建会丢掉其余文件。 + if skill.SourceType == skillSourceTypePlugin || skill.SourceType == skillSourceTypeLocal { + continue + } var extraFiles map[string][]byte if skill.Source != skillSourceUser { extraFiles = builtinFiles[skill.ID] @@ -207,7 +210,7 @@ func (s *Service) InstallSkillUpload(userID string, sourceType string, header *m return nil, kernel.BadAuthRequest("技能文件仅支持 Markdown 或 ZIP") } if header == nil || header.Size <= 0 || header.Size > maxSkillPackageBytes { - return nil, kernel.BadAuthRequest("技能文件大小必须在 1B-20MB 之间") + return nil, kernel.BadAuthRequest("技能文件大小必须在 1B-200MB 之间") } file, err := header.Open() if err != nil { @@ -219,7 +222,7 @@ func (s *Service) InstallSkillUpload(userID string, sourceType string, header *m return nil, err } if len(data) > maxSkillPackageBytes { - return nil, kernel.BadAuthRequest("技能文件不能超过 20MB") + return nil, errSkillPackageTooLarge() } var archive skillPackageArchive if sourceType == "markdown" { @@ -381,9 +384,10 @@ func (s *Service) createSkillFromArchive(userID string, archive skillPackageArch if utf8.RuneCountInString(name) == 0 || utf8.RuneCountInString(name) > maxSkillNameRunes { return nil, kernel.BadAuthRequest("技能名称必须为 1-80 个字符") } - if utf8.RuneCountInString(description) == 0 || utf8.RuneCountInString(description) > maxSkillDescriptionRunes { - return nil, kernel.BadAuthRequest("技能简介必须为 1-500 个字符") + if utf8.RuneCountInString(description) == 0 { + return nil, kernel.BadAuthRequest("技能简介不能为空") } + description = fitRunes(description, maxSkillDescriptionRunes) tag := strings.TrimSpace(req.Tag) if _, ok := skillCategoryLabels[tag]; !ok { tag = "others" @@ -663,7 +667,7 @@ func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath st return nil, err } defer file.Close() - return io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + return io.ReadAll(io.LimitReader(file, maxSkillPackageBytes+1)) } return nil, kernel.BadAuthRequest("技能文件不存在") } diff --git a/backend/internal/skills/skill_packages_test.go b/backend/internal/skills/skill_packages_test.go index 589be7a9b..524444ff1 100644 --- a/backend/internal/skills/skill_packages_test.go +++ b/backend/internal/skills/skill_packages_test.go @@ -328,7 +328,7 @@ func TestBuiltinSeedSkillsIncludeLuckyPlusOnePackagesAndAuxiliaryFiles(t *testin } func TestNormalizeBuiltinSkillFilesRejectsTotalSizeOverflow(t *testing.T) { - chunk := strings.Repeat("a", 7<<20) + chunk := strings.Repeat("a", 70<<20) if _, err := normalizeBuiltinSkillFiles(map[string]string{ "references/one.md": chunk, "references/two.md": chunk, diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index e51e13d2a..791c08a05 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -82,6 +82,7 @@ type SkillItem struct { ExtraInfo string `json:"extraInfo"` IsAdded bool `json:"isAdded"` IsOwner bool `json:"isOwner"` + HasScripts bool `json:"hasScripts"` MarketRelease *skillhub.Release `json:"marketRelease,omitempty"` } @@ -296,6 +297,10 @@ func (s *Service) skillItems(userID string, skills []model.Skill, includeInstruc } releases[version.ID] = release } + withScripts, err := s.repo.SkillVersionsWithScripts(versionIDs) + if err != nil { + return nil, err + } states, err := s.repo.UserSkillStatesBySkillIDs(userID, ids) if err != nil { return nil, err @@ -338,7 +343,7 @@ func (s *Service) skillItems(userID string, skills []model.Skill, includeInstruc LikeCount: metric.LikeCount, IsLike: state.Liked, OwnerUID: skill.OwnerID, EffectiveUser: SkillEffectiveUser{Name: ownerName, AvatarURL: ownerAvatarURL, UID: skill.OwnerID}, ShowcaseMedia: showcaseMedia, AddedCount: metric.AddedCount, ExtraInfo: skill.ExtraInfo, IsAdded: state.Added, IsOwner: skill.OwnerID == userID, - MarketRelease: release, + HasScripts: withScripts[skill.CurrentVersionID], MarketRelease: release, }) } return items, nil @@ -426,7 +431,7 @@ func normalizeSkillMutationRequest(req SkillMutationRequest, requireInstruction return req, "", kernel.BadAuthRequest("技能名称必须为 1-80 个字符") } if req.Description == "" || utf8.RuneCountInString(req.Description) > maxSkillDescriptionRunes { - return req, "", kernel.BadAuthRequest("技能简介必须为 1-500 个字符") + return req, "", kernel.BadAuthRequest("技能简介必须为 1-1024 个字符") } if (requireInstruction && req.Instruction == "") || utf8.RuneCountInString(req.Instruction) > 100000 { return req, "", kernel.BadAuthRequest("技能指令必须为 1-100000 个字符") diff --git a/backend/internal/skills/skills_seed.go b/backend/internal/skills/skills_seed.go index ae01992fa..6c76fd350 100644 --- a/backend/internal/skills/skills_seed.go +++ b/backend/internal/skills/skills_seed.go @@ -116,9 +116,6 @@ func normalizeBuiltinSkillFiles(raw map[string]string) (map[string][]byte, error if len(raw) == 0 { return nil, nil } - if len(raw) >= maxSkillPackageFiles { - return nil, fmt.Errorf("辅助文件数量超过上限 %d", maxSkillPackageFiles-1) - } files := make(map[string][]byte, len(raw)) var total int64 for rawPath, content := range raw { @@ -127,7 +124,7 @@ func normalizeBuiltinSkillFiles(raw map[string]string) (map[string][]byte, error return nil, fmt.Errorf("辅助文件路径无效: %q", rawPath) } data := []byte(content) - if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) { + if len(data) == 0 || !utf8.Valid(data) { return nil, fmt.Errorf("辅助文件内容无效: %q", rawPath) } total += int64(len(data)) diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 418e1d018..41c2dae83 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -189,7 +189,9 @@ HTTPS 图片提交前读取同源 `/models` 元数据,仅成功响应明确声 | `skill_files` | 保存某个版本的文件索引 | `(skill_version_id, path)` 唯一;只保存路径、类型、MIME、大小和 SHA-256,不保存正文 | | `user_skill_states` | 保存用户的加入、收藏、安装版本和自动更新选择 | 技能正文读取仍经过公开性和归属校验,不能通过文件接口绕过私有技能权限 | -规范化技能包保存在后端数据目录的 `skill-packages//.zip`,数据库只保存索引和版本指针。上传 ZIP 时强制校验 `SKILL.md`、路径越界、软链接、重复路径、文件数量和解压大小;GitHub 技能保存公开仓库、分支、子目录和提交 SHA,后台每 6 小时检查一次开启自动更新的技能,并记录最近检查、同步时间及失败状态。 +规范化技能包保存在后端数据目录的 `skill-packages//.zip`,数据库只保存索引和版本指针。上传 ZIP 时强制校验 `SKILL.md`、路径越界、软链接、重复路径和解压后总大小(200MB 以内),不限制文件数量;GitHub 技能保存公开仓库、分支、子目录和提交 SHA,后台每 6 小时检查一次开启自动更新的技能,并记录最近检查、同步时间及失败状态。 + +本机技能来自 `~/.agents/skills` 目录,`source_type` 为 `local`,内容随目录同步,不在 BeefTV 内编辑。是否读取这个目录保存在 `system_settings` 的 `local_skills_directory` 键,没有这条记录时默认读取。 ## 提示词模板 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index b8c01df18..d8c0f9417 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -48,6 +48,8 @@ Windows 安装包自带视频处理工具,无需另外安装 FFmpeg。预览 「我的技能」支持查看文件、启用或停用、检查更新、回退到已安装的历史版本,以及复制为私人改写。文字技能不需要多模态模型;涉及媒体生成或理解的技能,需要相应的模型能力。 +Codex、Claude Code 等工具装在这台电脑上的技能(`~/.agents/skills`)会自动出现在「我的技能」,标记为「其他工具安装」。这些技能跟着本机目录走,要修改请改目录里的文件;不想显示时,在「其他工具的技能」里关闭。带脚本的技能会标「含脚本」:创作助手按说明工作,但不运行里面的脚本。 + 作者可在客户端准备技能投稿并继续草稿。准备阶段只保存在本机;上传私有草稿与提交公开审核需要分别确认。 ## 画布保存保护与版本记录 diff --git a/web/src/pages/skills/local-skill-source.tsx b/web/src/pages/skills/local-skill-source.tsx new file mode 100644 index 000000000..a21a37773 --- /dev/null +++ b/web/src/pages/skills/local-skill-source.tsx @@ -0,0 +1,48 @@ +import { App, Button, Popover } from "antd"; +import { Laptop } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Switch } from "@/components/ui/base/switch"; +import { getLocalSkillSource, setLocalSkillSource, type LocalSkillSource } from "@/services/api/skills"; + +/** 开关:读本机共用技能目录,让 Codex、Claude Code 等工具装的技能也出现在「我的技能」。 */ +export function LocalSkillSourceButton({ onChanged }: { onChanged: () => void }) { + const { message } = App.useApp(); + const [source, setSource] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + getLocalSkillSource().then(setSource).catch(() => setSource(null)); + }, []); + + const toggle = async (enabled: boolean) => { + setSaving(true); + try { + setSource(await setLocalSkillSource(enabled)); + onChanged(); + } catch (error) { + message.error(error instanceof Error ? error.message : "没有改成功,请重试"); + } finally { + setSaving(false); + } + }; + + if (!source) return null; + return ( + +
+ 显示其他工具的技能 + void toggle(checked)} /> +
+
Codex、Claude Code 等工具装在这台电脑上的技能,会自动出现在这里。要修改这些技能,请改这个目录里的文件:
+
{source.directory}
+ + )} + > + +
+ ); +} diff --git a/web/src/pages/skills/marketplace.css b/web/src/pages/skills/marketplace.css index d992be8ae..0f765cfeb 100644 --- a/web/src/pages/skills/marketplace.css +++ b/web/src/pages/skills/marketplace.css @@ -26,4 +26,4 @@ .skill-publish-status { padding: 16px 0; margin-bottom: 12px; border-bottom: 1px solid var(--border); } .skill-publish-status p { margin-bottom: 10px; } @media (max-width: 720px) { .app-section-page.skill-market-page .skill-market-header .app-section-page-header { min-height: 44px; padding-block: 6px; } .skill-market-page .skill-market-header .app-section-page-header p { display: none; } } -@media (max-width: 640px) { .skill-market-row { flex-direction: column; gap: 12px; } .skill-publish-layout { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 640px) { .skill-market-page .skill-market-toolbar { flex-wrap: wrap; } .skill-market-page .skill-market-toolbar .ant-input-search { flex: 1 1 100%; max-width: none; } .skill-market-row { flex-direction: column; gap: 12px; } .skill-publish-layout { grid-template-columns: minmax(0, 1fr); } } diff --git a/web/src/pages/skills/marketplace.tsx b/web/src/pages/skills/marketplace.tsx index da8f57184..ddd094ec0 100644 --- a/web/src/pages/skills/marketplace.tsx +++ b/web/src/pages/skills/marketplace.tsx @@ -1,9 +1,9 @@ -import { App, Alert, Button, Input, Select, Skeleton, Tabs, Tag } from "antd"; -import { useCallback, useEffect, useState } from "react"; +import { App, Alert, Button, Input, Select, Skeleton, Tabs, Tag, Tooltip } from "antd"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { PageHeader, WorkspacePage } from "@/components/layout/workspace-page"; import { AppModal } from "@/components/ui/product/app-modal"; -import { addSkill, deleteSkill, getSkill, listSkills, removeSkill, type Skill } from "@/services/api/skills"; +import { addSkill, deleteSkill, getSkill, listSkills, removeSkill, syncLocalSkills, type Skill } from "@/services/api/skills"; import { forkSkill, getInstalledVersions, getMarketRelease, installMarketRelease, rollbackSkill, searchSkillMarket, type InstalledVersion, type MarketItem, type MarketRelease } from "@/services/api/skill-market"; import { SkillEditorDrawer } from "./skill-editor-drawer"; import { SkillInstallModal } from "./skill-install-modal"; @@ -12,6 +12,8 @@ import { SkillPublishingPanel } from "./publishing-panel"; import { MarketFilePreview } from "./market-file-preview"; import { MarketReviewSummary } from "./market-review-summary"; import { capabilityOptions, requirementLabel } from "./market-copy"; +import { LocalSkillSourceButton } from "./local-skill-source"; +import { isLocalSkill, LOCAL_SKILL_LABEL, SKILL_SCRIPTS_NOTE } from "./skill-catalog"; import "./marketplace.css"; const SKILL_PAGE_SIZE = 20; @@ -37,6 +39,9 @@ export default function SkillMarketplace() { const [versions, setVersions] = useState<{ skill: Skill; items: InstalledVersion[] } | null>(null); const [versionId, setVersionId] = useState(""); const reload = useCallback(() => setRevision(n => n + 1), []); + // 别的工具刚装的技能,打开「我的技能」就能看到。读不到本机目录时列表照常显示。 + const localSkillSync = useRef | null>(null); + const refresh = () => { localSkillSync.current = null; reload(); }; const changeTab = (next: string) => { setParams({ tab: next }); setPage(1); setQuery(""); }; useEffect(() => { if (tab === "publish") return; @@ -51,7 +56,7 @@ export default function SkillMarketplace() { }; const work = tab === "market" ? searchSkillMarket(query, page).then(value => { if (!stale && clampPage(value.total)) setItems(value.items || []); }) - : listSkills({ scope: "mine", page, pageSize: SKILL_PAGE_SIZE, search: query }).then(value => { if (!stale && clampPage(value.totalCount)) setLocal(value.skills); }); + : (localSkillSync.current ??= syncLocalSkills().catch(() => undefined)).then(() => listSkills({ scope: "mine", page, pageSize: SKILL_PAGE_SIZE, search: query })).then(value => { if (!stale && clampPage(value.totalCount)) setLocal(value.skills); }); void work.catch(e => { if (!stale) setError(e.message || "暂时无法读取技能"); }).finally(() => { if (!stale) setLoading(false); }); }, 200); return () => { stale = true; clearTimeout(timer); }; @@ -82,16 +87,16 @@ export default function SkillMarketplace() { {tab === "publish" ? : <> -
{ setQuery(e.target.value); setPage(1); }} />
+
{ setQuery(e.target.value); setPage(1); }} />{tab === "mine" ? : null}
{error ? 重试} /> : loading ? : <> {tab === "market" ? items.map(item =>

{item.name} {item.versionLabel}

{item.description}

{item.author}{item.license}{item.verification === "tested" ? "有实测记录" : "效果尚未验证"}
{item.compatibility?.status === "unsupported" ?

当前环境不满足:{item.compatibility.reasons?.join("、")}

: null}
) : local.map(skill =>
-

{skill.skillName} {skill.version} {!skill.isAdded ? 已停用 : null}

{skill.description}

{skill.sourceType === "market" ? "市场固定版本" : skill.sourceType === "builtin" ? "内置技能" : "本机技能"}{skill.effectiveUser?.name ? {skill.effectiveUser.name} : null}{skill.fileCount} 个文件
+

{skill.skillName} {skill.version} {!skill.isAdded ? 已停用 : null}

{skill.description}

{isLocalSkill(skill) ? LOCAL_SKILL_LABEL : skill.sourceType === "market" ? "市场固定版本" : skill.sourceType === "builtin" ? "内置技能" : "本机技能"}{skill.effectiveUser?.name && !isLocalSkill(skill) ? {skill.effectiveUser.name} : null}{skill.fileCount} 个文件{skill.hasScripts ? 含脚本 : null}
- {skill.sourceType === "market" ? <> : skill.sourceType === "builtin" && !skill.isOwner ? : <>} - {skill.isOwner ? : null}
+ {skill.sourceType === "market" ? <> : skill.sourceType === "builtin" && !skill.isOwner ? : isLocalSkill(skill) ? null : <>} + {skill.isOwner && !isLocalSkill(skill) ? : null}
)} {total === 0 ?

{tab === "mine" ? "还没有技能。可以从市场安装,或导入自己的 SKILL.md。" : "没有找到匹配的技能。"}

: null} diff --git a/web/src/pages/skills/skill-catalog.ts b/web/src/pages/skills/skill-catalog.ts index d75574836..f0ecf1c70 100644 --- a/web/src/pages/skills/skill-catalog.ts +++ b/web/src/pages/skills/skill-catalog.ts @@ -8,6 +8,14 @@ export const fallbackSkillCategories: SkillCategory[] = [ { value: "others", label: "其他" }, ]; +/** 其他工具装的技能跟着本机目录走,在这里改了下次同步就会被覆盖,所以不给编辑和删除。 */ +export function isLocalSkill(skill: Pick) { + return skill.sourceType === "local"; +} + +export const LOCAL_SKILL_LABEL = "其他工具安装"; +export const SKILL_SCRIPTS_NOTE = "助手会按说明工作,但不会运行里面的脚本"; + export function skillCategoryLabel(value: string, categories: SkillCategory[] = fallbackSkillCategories) { return categories.find((item) => item.value === value)?.label || "其他"; } diff --git a/web/src/pages/skills/skill-detail-drawer.tsx b/web/src/pages/skills/skill-detail-drawer.tsx index 692ab7813..e05820865 100644 --- a/web/src/pages/skills/skill-detail-drawer.tsx +++ b/web/src/pages/skills/skill-detail-drawer.tsx @@ -9,7 +9,7 @@ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "r import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { formatSkillCount, formatSkillDate, skillCategoryLabel } from "@/pages/skills/skill-catalog"; +import { formatSkillCount, formatSkillDate, isLocalSkill, LOCAL_SKILL_LABEL, SKILL_SCRIPTS_NOTE, skillCategoryLabel } from "@/pages/skills/skill-catalog"; import { isLocalRuntimeMode } from "@/lib/runtime-mode"; import { getSkillFile, listSkillFiles, skillFileRawURL, type Skill, type SkillCategory, type SkillPackageFile, type SkillPackageFileContent } from "@/services/api/skills"; @@ -106,10 +106,11 @@ export function SkillDetailModal({ skill, loading, mutating, categories, onClose

{skill.skillName}

{skill.description}

+ {skill.hasScripts ?

含脚本:{SKILL_SCRIPTS_NOTE}。

: null} {!fileOnly ?
{!localRuntime && skill.sourceType === "github" && skill.isOwner ? : null} - {skill.isOwner ? : null} + {skill.isOwner && !isLocalSkill(skill) ? : null}
: null} @@ -271,6 +272,8 @@ function sourceLabel(source: string) { if (source === "github") return "GitHub"; if (source === "zip") return "ZIP 技能包"; if (source === "builtin") return "内置技能"; + if (source === "local") return LOCAL_SKILL_LABEL; + if (source === "plugin") return "插件"; return "Markdown"; } diff --git a/web/src/pages/skills/skill-editor-drawer.tsx b/web/src/pages/skills/skill-editor-drawer.tsx index 6e47406b0..d1c9fb9db 100644 --- a/web/src/pages/skills/skill-editor-drawer.tsx +++ b/web/src/pages/skills/skill-editor-drawer.tsx @@ -130,8 +130,8 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo - - + + {!isPackageSkill ? diff --git a/web/src/pages/skills/skill-install-modal.tsx b/web/src/pages/skills/skill-install-modal.tsx index 1a81f247e..60251ab0f 100644 --- a/web/src/pages/skills/skill-install-modal.tsx +++ b/web/src/pages/skills/skill-install-modal.tsx @@ -116,8 +116,8 @@ export function SkillInstallModal({ open, onClose, onInstalled, onManualCreate } maxCount={1} fileList={fileList} beforeUpload={(file) => { - if (file.size > 20 * 1024 * 1024) { - message.error("技能文件不能超过 20MB"); + if (file.size > 200 * 1024 * 1024) { + message.error("技能文件不能超过 200MB"); return Upload.LIST_IGNORE; } return false; diff --git a/web/src/services/api/skills.ts b/web/src/services/api/skills.ts index 23e0bf090..cbe66cc01 100644 --- a/web/src/services/api/skills.ts +++ b/web/src/services/api/skills.ts @@ -26,7 +26,7 @@ export type Skill = { contentHash: string; fileCount: number; totalBytes: number; - sourceType: "builtin" | "markdown" | "zip" | "github" | string; + sourceType: "builtin" | "markdown" | "zip" | "github" | "plugin" | "local" | string; sourceUrl: string; sourceRef: string; sourceSubdir: string; @@ -56,8 +56,11 @@ export type Skill = { extraInfo: string; isAdded: boolean; isOwner: boolean; + hasScripts: boolean; }; +export type LocalSkillSource = { enabled: boolean; directory: string }; + export type SkillCategory = { value: string; label: string }; export type SkillList = { @@ -213,6 +216,18 @@ export function searchSkillFiles(id: string, query: string) { return http.get<{ results: SkillFileSearchResult[] }>(`/skills/${encodeURIComponent(id)}/search`, { params: { q: query } }); } +export function getLocalSkillSource() { + return http.get("/skills/local-source"); +} + +export function setLocalSkillSource(enabled: boolean) { + return http.put("/skills/local-source", { enabled }).finally(invalidateAddedSkillsCache); +} + +export function syncLocalSkills() { + return http.post<{ synced: boolean }>("/skills/local-source/sync").finally(invalidateAddedSkillsCache); +} + export function syncSkill(id: string) { return http.post<{ skill: Skill }>(`/skills/${encodeURIComponent(id)}/sync`).finally(invalidateAddedSkillsCache); }