From fb4af9bd34b6ac829949896392c47ac925dabbf4 Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:24:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E6=8A=80=E8=83=BD=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=80=89=E7=9A=84?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AD=90=E5=88=86=E7=B1=BB=E7=AD=96=E5=B1=95?= =?UTF-8?q?=20(#693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- .gitignore | 3 +- backend/internal/app/skill_curation_bridge.go | 16 ++ backend/internal/database/migrations.go | 4 +- backend/internal/database/skill_curation.go | 14 ++ .../internal/database/skill_curation_test.go | 104 +++++++++++ backend/internal/handler/skill_curation.go | 54 ++++++ .../internal/handler/skill_curation_test.go | 62 +++++++ backend/internal/handler/skills.go | 2 + backend/internal/model/skill_curation.go | 21 +++ backend/internal/repository/builtin_skills.go | 7 + backend/internal/repository/skill_curation.go | 114 ++++++++++++ backend/internal/repository/skills.go | 33 +++- .../service/aliases_skill_curation.go | 6 + backend/internal/skills/skill_curation.go | 148 ++++++++++++++++ .../internal/skills/skill_curation_test.go | 166 ++++++++++++++++++ backend/internal/skills/skills.go | 23 ++- .../content/docs/backend/backend-database.mdx | 4 + docs/content/docs/backend/code-map.mdx | 4 + docs/content/docs/backend/http-api.mdx | 4 + docs/content/docs/backend/skill-curation.mdx | 33 ++++ docs/content/docs/overview/features.mdx | 4 + docs/content/docs/progress/pending-test.mdx | 4 + .../canvas-agent-skill-library-modal.tsx | 9 +- .../canvas/canvas-cloud-agent-panel.tsx | 17 +- .../skills/skill-curation-browser.tsx | 47 +++++ .../pages/admin/components/admin-shell.tsx | 1 + web/src/pages/admin/skill-curation-page.tsx | 59 +++++++ web/src/pages/skills/index.tsx | 9 +- web/src/router.tsx | 2 + web/src/services/api/skill-curation.ts | 9 + web/src/services/api/skills.ts | 2 + web/test/skill-curation.test.ts | 20 +++ 32 files changed, 981 insertions(+), 24 deletions(-) create mode 100644 backend/internal/app/skill_curation_bridge.go create mode 100644 backend/internal/database/skill_curation.go create mode 100644 backend/internal/database/skill_curation_test.go create mode 100644 backend/internal/handler/skill_curation.go create mode 100644 backend/internal/handler/skill_curation_test.go create mode 100644 backend/internal/model/skill_curation.go create mode 100644 backend/internal/repository/skill_curation.go create mode 100644 backend/internal/service/aliases_skill_curation.go create mode 100644 backend/internal/skills/skill_curation.go create mode 100644 backend/internal/skills/skill_curation_test.go create mode 100644 docs/content/docs/backend/skill-curation.mdx create mode 100644 web/src/components/skills/skill-curation-browser.tsx create mode 100644 web/src/pages/admin/skill-curation-page.tsx create mode 100644 web/src/services/api/skill-curation.ts create mode 100644 web/test/skill-curation.test.ts diff --git a/.gitignore b/.gitignore index 3c5c707c1..4c33f16c4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ docs/content/docs/plugins/* # Keep the task stability runbook versioned without unignoring generated docs. !docs/content/docs/backend/ docs/content/docs/backend/* +!docs/content/docs/backend/skill-curation.mdx !docs/content/docs/backend/task-runtime-stability.mdx !docs/content/docs/backend/agent-prompt-policy.mdx !docs/content/docs/backend/canvas-agent-appearance.mdx @@ -76,4 +77,4 @@ backend/cmd/server/server backend/tmp_* # Keep the runtime in source control; its optional debug map is not deployed. /web/public/live2d/live2dcubismcore.min.js.map -.zocde/ \ No newline at end of file +.zocde/ diff --git a/backend/internal/app/skill_curation_bridge.go b/backend/internal/app/skill_curation_bridge.go new file mode 100644 index 000000000..61809e1f1 --- /dev/null +++ b/backend/internal/app/skill_curation_bridge.go @@ -0,0 +1,16 @@ +package app + +import ( + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/skills" +) + +type SkillCuration = skills.SkillCuration +type SkillCurationUpdate = skills.SkillCurationUpdate + +func (s *Service) SkillCuration(actor *model.User, admin bool) (*SkillCuration, error) { + return s.skillDomain().SkillCuration(actor, admin) +} +func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate, requestID string) (*SkillCuration, error) { + return s.skillDomain().UpdateSkillCuration(actor, req, requestID) +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index bcf2de164..51796487f 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 43 +const CurrentSchemaVersion int64 = 46 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -55,6 +55,7 @@ type migration struct { } var schemaMigrations = []migration{ + // 44 and 45 are reserved for independent migrations. {version: 1, name: "baseline_gorm_schema", checksum: baselineSchemaChecksum, apply: migrateSchemaV1}, {version: 2, name: "schema_migrations_applied_at_index", checksum: schemaMigrationAppliedAtIndexChecksum, apply: migrateSchemaV2}, {version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3}, @@ -145,6 +146,7 @@ var schemaMigrations = []migration{ {version: 43, name: "topup_sale_strategies", checksum: "sha256:topup-sale-strategies-v43-20260929", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.TopupProduct{}, &model.PaymentOrder{}) }}, + {version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/database/skill_curation.go b/backend/internal/database/skill_curation.go new file mode 100644 index 000000000..066c3bf9c --- /dev/null +++ b/backend/internal/database/skill_curation.go @@ -0,0 +1,14 @@ +package database + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func migrateSkillCuration(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}); err != nil { + return err + } + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SkillCurationSetting{ID: 1}).Error +} diff --git a/backend/internal/database/skill_curation_test.go b/backend/internal/database/skill_curation_test.go new file mode 100644 index 000000000..0d4da1186 --- /dev/null +++ b/backend/internal/database/skill_curation_test.go @@ -0,0 +1,104 @@ +package database + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "path/filepath" + "testing" +) + +func TestSkillCurationUpgradeAndRollback(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "upgrade.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + if err := db.AutoMigrate(&schemaMigration{}); err != nil { + t.Fatal(err) + } + for _, m := range schemaMigrations { + if m.version > 43 { + break + } + if err := db.Transaction(func(tx *gorm.DB) error { + if err := m.apply(tx); err != nil { + return err + } + return tx.Create(&schemaMigration{Version: m.version, Name: m.name, Checksum: m.checksum}).Error + }); err != nil { + t.Fatal(err) + } + } + if db.Migrator().HasTable(&model.SkillCurationSetting{}) { + t.Fatal("fixture is not an original schema") + } + if err := db.Create(&model.Skill{ID: "existing", Name: "Existing", Tag: "drama", Status: 1}).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec(`CREATE TRIGGER reject_curation_migration BEFORE INSERT ON schema_migrations WHEN NEW.version = 46 BEGIN SELECT RAISE(ABORT, 'migration interrupted'); END`).Error; err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err == nil { + t.Fatal("forced migration failure accepted") + } + if db.Migrator().HasTable(&model.SkillCurationSetting{}) { + t.Fatal("failed migration left partial tables") + } + if err := db.Exec("DROP TRIGGER reject_curation_migration").Error; err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + var skill model.Skill + if err := db.First(&skill, "id = ?", "existing").Error; err != nil || skill.Tag != "drama" { + t.Fatalf("existing skill changed: %#v %v", skill, err) + } + var setting model.SkillCurationSetting + if err := db.First(&setting, 1).Error; err != nil || setting.Enabled { + t.Fatalf("upgrade enabled curation: %#v %v", setting, err) + } +} + +func TestSkillCurationMigration(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migration.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + for i := 0; i < 2; i++ { + if err := db.Transaction(migrateSkillCuration); err != nil { + t.Fatal(err) + } + } + var state model.SkillCurationSetting + if err := db.First(&state, 1).Error; err != nil { + t.Fatal(err) + } + if state.Enabled || state.Revision != 0 { + t.Fatalf("migration must default off: %#v", state) + } + db.Model(&state).Updates(map[string]any{"enabled": true, "revision": 3}) + if err := db.Transaction(migrateSkillCuration); err != nil { + t.Fatal(err) + } + db.First(&state, 1) + if !state.Enabled || state.Revision != 3 { + t.Fatal("migration overwrote settings") + } + found := false + for _, m := range schemaMigrations { + if m.version == 46 { + found = m.name == "skill_curation" && m.checksum == "sha256:skill-curation-v46-20261005" + } + } + if !found { + t.Fatal("migration 46 is not registered") + } +} diff --git a/backend/internal/handler/skill_curation.go b/backend/internal/handler/skill_curation.go new file mode 100644 index 000000000..2f39ee19d --- /dev/null +++ b/backend/internal/handler/skill_curation.go @@ -0,0 +1,54 @@ +package handler + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "infinite-canvas/backend/internal/service" + "io" + "net/http" +) + +func registerSkillCurationRoutes(r *gin.RouterGroup, svc *service.Service) { + read := func(admin bool) gin.HandlerFunc { + return func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + result, err := svc.SkillCuration(actor, admin) + if err != nil { + failService(c, err) + return + } + ok(c, result) + } + } + r.GET("/skills/curation", read(false)) + r.GET("/admin/skill-curation", read(true)) + r.PUT("/admin/skill-curation", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + dec := json.NewDecoder(c.Request.Body) + dec.DisallowUnknownFields() + var req service.SkillCurationUpdate + if err := dec.Decode(&req); err != nil { + failService(c, service.BadAuthRequest("分类请求格式无效")) + return + } + if err := dec.Decode(new(any)); err != io.EOF { + failService(c, service.BadAuthRequest("只允许一个请求对象")) + return + } + result, err := svc.UpdateSkillCuration(actor, req, RequestID(c)) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) +} diff --git a/backend/internal/handler/skill_curation_test.go b/backend/internal/handler/skill_curation_test.go new file mode 100644 index 000000000..9a9b6872b --- /dev/null +++ b/backend/internal/handler/skill_curation_test.go @@ -0,0 +1,62 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "infinite-canvas/backend/internal/auth" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestSkillCurationHTTP(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + defer sqlDB.Close() + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.Skill{}, &model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + db.Create(&model.SkillCurationSetting{ID: 1}) + for _, role := range []model.UserRole{model.UserRoleAdmin, model.UserRoleUser} { + db.Create(&model.User{ID: string(role), Username: string(role), Role: role, Status: model.UserStatusActive}) + db.Create(&model.AuthSession{ID: string(role), UserID: string(role), TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}) + } + router := gin.New() + RegisterSkillRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir())) + for _, tc := range []struct { + role, method, path, body string + status int + }{ + {"", "GET", "/api/skills/curation", "", 401}, + {"user", "GET", "/api/admin/skill-curation", "", 403}, + {"user", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true}`, 403}, + {"user", "GET", "/api/skills/curation", "", 200}, + {"admin", "PUT", "/api/admin/skill-curation", `{"enabled":true}`, 400}, + {"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true,"requestId":"forged"}`, 400}, + {"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true} {}`, 400}, + {"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true}`, 200}, + {"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":false}`, 409}, + } { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + if tc.role != "" { + req.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: tc.role + ".test-token"}) + } + out := httptest.NewRecorder() + router.ServeHTTP(out, req) + if out.Code != tc.status { + t.Fatalf("%s %s %s: %d %s", tc.role, tc.method, tc.path, out.Code, out.Body.String()) + } + } +} diff --git a/backend/internal/handler/skills.go b/backend/internal/handler/skills.go index 1a2bceb34..d4d09fd7b 100644 --- a/backend/internal/handler/skills.go +++ b/backend/internal/handler/skills.go @@ -15,6 +15,7 @@ import ( ) func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { + registerSkillCurationRoutes(r, svc) r.POST("/skills/install", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -88,6 +89,7 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { return } result, err := svc.Skills(user.ID, service.SkillListRequest{ + PlatformCategoryID: c.Query("platformCategoryId"), PlatformUncategorized: c.Query("platformUncategorized") == "true", Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"), Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"), LibraryCategoryID: c.Query("libraryCategoryId"), LibraryUncategorized: libraryUncategorized, diff --git a/backend/internal/model/skill_curation.go b/backend/internal/model/skill_curation.go new file mode 100644 index 000000000..b0065ed92 --- /dev/null +++ b/backend/internal/model/skill_curation.go @@ -0,0 +1,21 @@ +package model + +type SkillCurationSetting struct { + ID int `gorm:"primaryKey;autoIncrement:false" json:"-"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` +} + +type SkillCurationCategory struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + RootTag string `gorm:"size:36;not null;uniqueIndex:idx_curation_category_name" json:"rootTag"` + Name string `gorm:"size:64;not null" json:"name"` + NormalizedName string `gorm:"size:128;not null;uniqueIndex:idx_curation_category_name" json:"-"` + SortOrder int `json:"sortOrder"` + Enabled bool `json:"enabled"` +} + +type SkillCurationAssignment struct { + SkillID string `gorm:"primaryKey;size:36" json:"skillId"` + CategoryID string `gorm:"primaryKey;size:36;index" json:"categoryId"` +} diff --git a/backend/internal/repository/builtin_skills.go b/backend/internal/repository/builtin_skills.go index b0ac70369..9a3874ef8 100644 --- a/backend/internal/repository/builtin_skills.go +++ b/backend/internal/repository/builtin_skills.go @@ -21,6 +21,13 @@ func (r *Repository) BuiltinSkillTombstoned(skillID string) (bool, error) { func (r *Repository) DeleteBuiltinSkill(skillID string, actorID string) error { now := time.Now().UTC() return r.db.Transaction(func(tx *gorm.DB) error { + var locked model.Skill + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, "id = ?", skillID).Error; err != nil { + return err + } + if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", skillID).Error; err != nil { + return err + } if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "skill_id"}}, DoUpdates: clause.AssignmentColumns([]string{"deleted_by", "deleted_at"})}).Create(&model.BuiltinSkillTombstone{SkillID: skillID, DeletedBy: actorID, DeletedAt: now}).Error; err != nil { return err } diff --git a/backend/internal/repository/skill_curation.go b/backend/internal/repository/skill_curation.go new file mode 100644 index 000000000..03661511a --- /dev/null +++ b/backend/internal/repository/skill_curation.go @@ -0,0 +1,114 @@ +package repository + +import ( + "encoding/json" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +type SkillCuration struct { + model.SkillCurationSetting + Categories []model.SkillCurationCategory `json:"categories"` + Assignments []model.SkillCurationAssignment `json:"assignments"` +} + +// Read the version before and after the snapshot so concurrent edits cannot +// produce a mixed snapshot that a client could save with a newer revision. +func (r *Repository) SkillCuration(admin bool) (*SkillCuration, error) { + for attempt := 0; attempt < 3; attempt++ { + out := &SkillCuration{Categories: []model.SkillCurationCategory{}, Assignments: []model.SkillCurationAssignment{}} + if err := r.db.First(&out.SkillCurationSetting, 1).Error; err != nil { + return nil, err + } + if !admin && !out.Enabled { + return out, nil + } + query := r.db.Order("sort_order, id") + if !admin { + query = query.Where("enabled = ?", true) + } + if err := query.Find(&out.Categories).Error; err != nil { + return nil, err + } + query = r.db.Model(&model.SkillCurationAssignment{}).Select("skill_curation_assignments.*"). + Joins("JOIN skills ON skills.id = skill_curation_assignments.skill_id").Where("skills.status = 1 AND skills.is_private = false") + if !admin { + query = query.Joins("JOIN skill_curation_categories c ON c.id = skill_curation_assignments.category_id").Where("c.enabled = true AND c.root_tag = skills.tag") + } + if err := query.Order("skill_id, category_id").Find(&out.Assignments).Error; err != nil { + return nil, err + } + var after model.SkillCurationSetting + if err := r.db.First(&after, 1).Error; err != nil { + return nil, err + } + if after.Revision == out.Revision { + return out, nil + } + } + return nil, kernel.NewAppError(409, "分类正在更新,请重新加载") +} + +func (r *Repository) UpdateSkillCuration(actorID string, revision int64, requestID string, change func(*Repository) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.SkillCurationSetting{}).Where("id = 1 AND revision = ?", revision).Update("revision", revision+1) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return kernel.NewAppError(409, "分类版本已更新,请重新加载") + } + var actor model.User + if err := tx.First(&actor, "id = ?", actorID).Error; err != nil { + return err + } + if actor.Role != model.UserRoleAdmin || actor.Status != model.UserStatusActive { + return kernel.Forbidden("需要管理员权限") + } + repo := New(tx) + before, err := repo.SkillCuration(true) + if err != nil { + return err + } + if err := change(repo); err != nil { + return err + } + after, err := repo.SkillCuration(true) + if err != nil { + return err + } + before.Revision = revision + metadata, err := json.Marshal(map[string]any{"requestId": requestID, "before": before, "after": after}) + if err != nil { + return err + } + return tx.Create(&model.AdminAuditEvent{ID: kernel.NewID(), ActorUserID: actorID, Action: "skill_curation.update", TargetType: "skill_curation", TargetID: "1", MetadataJSON: string(metadata)}).Error + }) +} + +func (r *Repository) SetSkillCurationEnabled(enabled bool) error { + return r.db.Model(&model.SkillCurationSetting{}).Where("id = 1").Update("enabled", enabled).Error +} +func (r *Repository) SaveSkillCurationCategory(category *model.SkillCurationCategory) error { + return r.db.Save(category).Error +} +func (r *Repository) CurationSkill(id string) (*model.Skill, error) { + var row model.Skill + err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, "id = ? AND status = 1 AND is_private = false", id).Error + return &row, err +} +func (r *Repository) AssignSkillCuration(skillID string, ids []string) error { + if err := r.db.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", skillID).Error; err != nil { + return err + } + for _, id := range ids { + if err := r.db.Create(&model.SkillCurationAssignment{SkillID: skillID, CategoryID: id}).Error; err != nil { + return err + } + } + return nil +} + +const effectiveCurationCategory = `SELECT 1 FROM skill_curation_assignments a JOIN skill_curation_categories c ON c.id = a.category_id WHERE a.skill_id = skills.id AND c.enabled = true AND c.root_tag = skills.tag AND skills.is_private = false` diff --git a/backend/internal/repository/skills.go b/backend/internal/repository/skills.go index 20c2cc4cd..d0e51670c 100644 --- a/backend/internal/repository/skills.go +++ b/backend/internal/repository/skills.go @@ -11,15 +11,17 @@ import ( ) type SkillListFilter struct { - UserID string - Scope string - Search string - Tag string - LibraryCategoryID string - LibraryUncategorized bool - Sort string - Limit int - Offset int + PlatformCategoryID string + PlatformUncategorized bool + UserID string + Scope string + Search string + Tag string + LibraryCategoryID string + LibraryUncategorized bool + Sort string + Limit int + Offset int } type SkillMetrics struct { @@ -77,6 +79,12 @@ func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error if filter.Tag != "" { query = query.Where("skills.tag = ?", filter.Tag) } + if filter.PlatformCategoryID != "" { + query = query.Where("EXISTS ("+effectiveCurationCategory+" AND c.id = ?)", filter.PlatformCategoryID) + } + if filter.PlatformUncategorized { + query = query.Where("NOT EXISTS (" + effectiveCurationCategory + ")") + } if filter.LibraryCategoryID != "" { query = query.Where("user_skill_states.library_category_id = ?", filter.LibraryCategoryID) } else if filter.LibraryUncategorized { @@ -132,6 +140,13 @@ func (r *Repository) CreateSkill(skill *model.Skill, ownerState *model.UserSkill func (r *Repository) DeleteSkill(id string) error { return r.db.Transaction(func(tx *gorm.DB) error { + var locked model.Skill + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, "id = ?", id).Error; err != nil { + return err + } + if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", id).Error; err != nil { + return err + } if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil { return err } diff --git a/backend/internal/service/aliases_skill_curation.go b/backend/internal/service/aliases_skill_curation.go new file mode 100644 index 000000000..355da3b30 --- /dev/null +++ b/backend/internal/service/aliases_skill_curation.go @@ -0,0 +1,6 @@ +package service + +import "infinite-canvas/backend/internal/app" + +type SkillCuration = app.SkillCuration +type SkillCurationUpdate = app.SkillCurationUpdate diff --git a/backend/internal/skills/skill_curation.go b/backend/internal/skills/skill_curation.go new file mode 100644 index 000000000..74d702d70 --- /dev/null +++ b/backend/internal/skills/skill_curation.go @@ -0,0 +1,148 @@ +package skills + +import ( + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "strings" + "unicode/utf8" +) + +type SkillCuration = repository.SkillCuration +type SkillCurationAssignmentInput struct { + SkillID string `json:"skillId"` + CategoryIDs []string `json:"categoryIds"` +} +type SkillCurationUpdate struct { + ExpectedRevision *int64 `json:"expectedRevision"` + Enabled *bool `json:"enabled,omitempty"` + Category *model.SkillCurationCategory `json:"category,omitempty"` + Assignment *SkillCurationAssignmentInput `json:"assignment,omitempty"` +} + +func (s *Service) SkillCuration(actor *model.User, admin bool) (*SkillCuration, error) { + if err := s.curationActor(actor, admin); err != nil { + return nil, err + } + return s.repo.SkillCuration(admin) +} +func (s *Service) curationActor(actor *model.User, admin bool) error { + if actor == nil || actor.ID == "" { + return kernel.Unauthorized("请先登录") + } + if admin { + current, err := s.repo.User(actor.ID) + if err != nil { + return err + } + if current.Role != model.UserRoleAdmin || current.Status != model.UserStatusActive { + return kernel.Forbidden("需要管理员权限") + } + } + return nil +} +func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate, requestID string) (*SkillCuration, error) { + if err := s.curationActor(actor, true); err != nil { + return nil, err + } + count := 0 + if req.Enabled != nil { + count++ + } + if req.Category != nil { + count++ + } + if req.Assignment != nil { + count++ + } + if req.ExpectedRevision == nil || *req.ExpectedRevision < 0 || count != 1 { + return nil, kernel.BadAuthRequest("需要版本号和一项修改") + } + err := s.repo.UpdateSkillCuration(actor.ID, *req.ExpectedRevision, requestID, func(repo *repository.Repository) error { + if req.Enabled != nil { + return repo.SetSkillCurationEnabled(*req.Enabled) + } + state, err := repo.SkillCuration(true) + if err != nil { + return err + } + if req.Category != nil { + c := *req.Category + c.Name = strings.Join(strings.Fields(c.Name), " ") + c.NormalizedName = strings.ToLower(c.Name) + if utf8.RuneCountInString(c.Name) < 1 || utf8.RuneCountInString(c.Name) > 64 || skillCategoryLabels[c.RootTag] == "" { + return kernel.BadAuthRequest("分类名称或一级分类无效") + } + exists := c.ID == "" + for _, old := range state.Categories { + if old.ID == c.ID { + exists = true + if old.RootTag != c.RootTag { + return kernel.BadAuthRequest("不能移动子分类") + } + } + if old.ID != c.ID && old.RootTag == c.RootTag && old.NormalizedName == c.NormalizedName { + return kernel.BadAuthRequest("同一级分类下名称不能重复") + } + } + if !exists { + return kernel.NotFound("分类不存在") + } + if c.ID == "" { + c.ID = kernel.NewID() + } + return repo.SaveSkillCurationCategory(&c) + } + a := req.Assignment + if a.CategoryIDs == nil || len(a.CategoryIDs) > 32 { + return kernel.BadAuthRequest("需要完整分类集合,最多32项") + } + skill, err := repo.CurationSkill(a.SkillID) + if err != nil { + return kernel.NotFound("公开技能不存在") + } + seen := map[string]bool{} + for _, id := range a.CategoryIDs { + valid := false + for _, c := range state.Categories { + if c.ID == id && c.Enabled && c.RootTag == skill.Tag { + valid = true + } + } + if !valid || seen[id] { + return kernel.BadAuthRequest("分类必须启用、同根且不能重复") + } + seen[id] = true + } + return repo.AssignSkillCuration(a.SkillID, a.CategoryIDs) + }) + if err != nil { + return nil, err + } + return s.repo.SkillCuration(true) +} + +func (s *Service) curationFilter(req SkillListRequest) (string, bool, error) { + if req.PlatformCategoryID == "" && !req.PlatformUncategorized { + return "", false, nil + } + state, err := s.repo.SkillCuration(false) + if err != nil { + return "", false, err + } + if !state.Enabled { + return "", false, nil + } + if req.PlatformCategoryID != "" && req.PlatformUncategorized { + return "", false, kernel.BadAuthRequest("分类和未细分不能同时筛选") + } + if req.PlatformCategoryID != "" { + for _, c := range state.Categories { + if c.ID == req.PlatformCategoryID { + return c.ID, false, nil + } + } + return "", false, kernel.BadAuthRequest("分类已停用或不存在,请重新选择") + } + return "", req.PlatformUncategorized, nil +} diff --git a/backend/internal/skills/skill_curation_test.go b/backend/internal/skills/skill_curation_test.go new file mode 100644 index 000000000..b19de8ebe --- /dev/null +++ b/backend/internal/skills/skill_curation_test.go @@ -0,0 +1,166 @@ +package skills + +import ( + "encoding/json" + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "reflect" + "testing" +) + +func TestSkillCurationManagementAvailable(t *testing.T) { + if _, ok := reflect.TypeOf((*Service)(nil)).MethodByName("UpdateSkillCuration"); !ok { + t.Fatal("missing platform curation management: skill categories are fixed and cannot be maintained by an administrator") + } +} + +func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) { + t.Helper() + svc, db := newSkillLibraryCategoryTestService(t) + if err := db.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SkillCurationSetting{ID: 1}).Error; err != nil { + t.Fatal(err) + } + actor := &model.User{ID: "curator", Role: model.UserRoleAdmin, Status: model.UserStatusActive, Username: "curator"} + if err := db.Create(actor).Error; err != nil { + t.Fatal(err) + } + return svc, db, actor +} + +func TestSkillCurationDisabledSnapshot(t *testing.T) { + svc, db, actor := newCurationTest(t) + // Snapshot of the original empty /skills data and fixed category order. + const original = `{"skills":[],"totalCount":0,"hasMore":false,"nextOffset":0,"page":1,"pageSize":20,"categories":[{"value":"drama","label":"短剧影视"},{"value":"ecommerce","label":"电商营销"},{"value":"creative","label":"创意设计"},{"value":"social","label":"社媒内容"},{"value":"others","label":"其他"}]}` + if err := db.Create(&model.SkillCurationCategory{ID: "hidden", RootTag: "drama", Name: "Hidden", NormalizedName: "hidden", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + for _, req := range []SkillListRequest{{}, {PlatformCategoryID: "invalid", PlatformUncategorized: true}} { + result, err := svc.Skills(actor.ID, req) + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(result) + if string(data) != original { + t.Fatalf("legacy response changed:\n%s", data) + } + } + result, err := svc.SkillCuration(actor, false) + if err != nil { + t.Fatal(err) + } + if result.Enabled || len(result.Categories) != 0 || len(result.Assignments) != 0 { + t.Fatalf("disabled mode leaked curation: %#v", result) + } +} + +func TestSkillCurationLifecycleAndGuards(t *testing.T) { + svc, db, actor := newCurationTest(t) + update := func(req SkillCurationUpdate) *SkillCuration { + t.Helper() + state, err := svc.SkillCuration(actor, true) + if err != nil { + t.Fatal(err) + } + req.ExpectedRevision = &state.Revision + out, err := svc.UpdateSkillCuration(actor, req, "test-request") + if err != nil { + t.Fatal(err) + } + return out + } + on := true + update(SkillCurationUpdate{Enabled: &on}) + state := update(SkillCurationUpdate{Category: &model.SkillCurationCategory{RootTag: "drama", Name: "Story", SortOrder: 2, Enabled: true}}) + c := state.Categories[0] + for _, s := range []model.Skill{{ID: "public", Name: "Story", Tag: "drama", Status: 1}, {ID: "other", Name: "Other", Tag: "social", Status: 1}, {ID: "private", Name: "Secret", Tag: "drama", Status: 1, IsPrivate: true, OwnerID: actor.ID}} { + if err := db.Create(&s).Error; err != nil { + t.Fatal(err) + } + } + state = update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "public", CategoryIDs: []string{c.ID}}}) + list, err := svc.Skills(actor.ID, SkillListRequest{PlatformCategoryID: c.ID}) + if err != nil || list.TotalCount != 1 { + t.Fatalf("filtered: %#v %v", list, err) + } + list, err = svc.Skills(actor.ID, SkillListRequest{PlatformUncategorized: true}) + if err != nil || list.TotalCount != 1 { + t.Fatalf("uncategorized: %#v %v", list, err) + } + for _, id := range []string{"other", "private"} { + _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Assignment: &SkillCurationAssignmentInput{SkillID: id, CategoryIDs: []string{c.ID}}}, "") + if err == nil { + t.Fatalf("accepted invalid assignment: %s", id) + } + } + stale := int64(0) + if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &stale, Enabled: &on}, ""); appErrorStatus(err) != 409 { + t.Fatalf("stale update: %v", err) + } + c.Name = "Renamed" + c.SortOrder = -1 + c.Enabled = false + state = update(SkillCurationUpdate{Category: &c}) + public, err := svc.SkillCuration(actor, false) + if err != nil || len(public.Categories) != 0 || len(public.Assignments) != 0 { + t.Fatalf("disabled category: %#v %v", public, err) + } + var assignments int64 + db.Model(&model.SkillCurationAssignment{}).Count(&assignments) + if assignments != 1 { + t.Fatal("disable deleted assignments") + } + c.Enabled = true + update(SkillCurationUpdate{Category: &c}) + off := false + update(SkillCurationUpdate{Enabled: &off}) + list, err = svc.Skills(actor.ID, SkillListRequest{PlatformCategoryID: c.ID}) + if err != nil || list.TotalCount != 2 { + t.Fatalf("disabled switch must ignore filters: %#v %v", list, err) + } + // A stale in-memory administrator cannot keep writing after revocation. + db.Model(&model.User{}).Where("id = ?", actor.ID).Update("role", model.UserRoleUser) + if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Enabled: &on}, ""); appErrorStatus(err) != 403 { + t.Fatalf("revoked administrator: %v", err) + } +} + +func TestSkillCurationAuditFailureRollsBack(t *testing.T) { + svc, db, actor := newCurationTest(t) + if err := db.Exec(`CREATE TRIGGER reject_curation_audit BEFORE INSERT ON admin_audit_events BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`).Error; err != nil { + t.Fatal(err) + } + revision := int64(0) + on := true + if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &revision, Enabled: &on}, ""); err == nil { + t.Fatal("audit failure accepted") + } + state, err := svc.SkillCuration(actor, true) + if err != nil || state.Revision != 0 || state.Enabled { + t.Fatalf("partial commit: %#v %v", state, err) + } +} + +func TestSkillCurationDeletionCleanup(t *testing.T) { + svc, db, _ := newCurationTest(t) + if err := db.AutoMigrate(&model.SkillVersion{}, &model.SkillFile{}, &model.BuiltinSkillTombstone{}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"normal", "builtin"} { + db.Create(&model.Skill{ID: id, Status: 1}) + db.Create(&model.SkillCurationAssignment{SkillID: id, CategoryID: "category"}) + } + if err := svc.repo.DeleteSkill("normal"); err != nil { + t.Fatal(err) + } + if err := svc.repo.DeleteBuiltinSkill("builtin", "admin"); err != nil { + t.Fatal(err) + } + var count int64 + db.Model(&model.SkillCurationAssignment{}).Count(&count) + if count != 0 { + t.Fatal("deleted skills retain assignments") + } +} diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index bc5b202f8..f6974df01 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -108,14 +108,16 @@ type SkillCategory struct { } type SkillListRequest struct { - Page int - PageSize int - Scope string - Search string - Tag string - LibraryCategoryID string - LibraryUncategorized bool - Sort string + PlatformCategoryID string + PlatformUncategorized bool + Page int + PageSize int + Scope string + Search string + Tag string + LibraryCategoryID string + LibraryUncategorized bool + Sort string } type SkillList struct { @@ -141,6 +143,10 @@ type SkillMutationRequest struct { func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error) { req = normalizeSkillListRequest(req) + categoryID, uncategorized, err := s.curationFilter(req) + if err != nil { + return nil, err + } if req.LibraryCategoryID != "" && req.LibraryUncategorized { return nil, kernel.BadAuthRequest("技能库分类与未分类筛选不能同时使用") } @@ -150,6 +156,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error } } rows, total, err := s.repo.Skills(repository.SkillListFilter{ + PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized, UserID: userID, Scope: req.Scope, Search: req.Search, diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index e6710d176..5afa8ef9f 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -297,3 +297,7 @@ Schema 32 为 `channel_models` 新增 `tags`(TEXT、NOT NULL、默认 `'[]'` - 启动幂等装载 87 个预设(风格 45、运镜 33、九宫格 9),不下载第三方媒体。显式 ID 与用户工具冲突时启动报错,不覆盖用户数据;PostgreSQL 同步自增序列。 - 工具预览纳入资源引用检查;自定义预览必须是当前用户已上传完成的资源。公开工具暂不接受私人预览资源。 - SQLite → PostgreSQL 迁移包含上述两表。首次启动前备份测试数据;回退旧代码不能视为数据库降级,应使用备份恢复,不手工重写迁移记录。 + +## 可选技能策展 + +迁移 46 新增 skill_curation_settings、skill_curation_categories、skill_curation_assignments;默认关闭,不导入技能内容。表结构与事务规则见 [平台技能策展](./skill-curation)。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index fd0dd6249..26f1646aa 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -159,3 +159,7 @@ HTTP 请求 -> handler -> service(稳定导入面 / 别名)-> app(组合 - 任务入队 `internal/app/task_creation.go` 与生成执行 `internal/app/provider.go` 共用严格 token 解析。 - 前端 `canvas-tool-presentation.ts` 共享轻量展示配置,`canvas-resource-references.ts` 统一 token;节点提示词与工具面板共用同一持久状态,不复制到 metadata 风格字段。 - `canvas-workspace-panel.tsx` 提供常驻左侧菜单,按需挂载节点、资产、工具、任务和历史面板;资产面板复用素材分页服务与画布插入合同;主分支项目章节侧栏与工作区互斥。 + +## 技能策展 + +`internal/skills/skill_curation.go` 负责校验,`internal/repository/skill_curation.go` 负责分类持久化与 CAS 审计交易,handler/app bridge 接入原有技能域。 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 6df1bca72..46489060a 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -389,3 +389,7 @@ CSV 使用中文表头、UTF-8 BOM、CRLF;金额按整数分精确转换为两 | DELETE | `/api/tools/:id` | 仅删除自己的用户工具,不能删除内置或他人工具 | 生成提示词工具引用格式为 `@[tool:type:ID:label:icon]`,label 使用 URI 编码。名称和图标只用于展示,不作为授权依据。服务端在任务入队及执行前检查用户可见性、启用状态、工具类型和生成模式;无效、越权、嵌套工具或数据库错误必须失败,不能把未解析 token 发送给模型。风格和九宫格仅用于图片,运镜和特效仅用于视频。入队校验不冻结工具快照:排队期间删除工具将导致执行失败。 + +## 平台技能策展 + +新增登录读取 `/api/skills/curation` 与管理员 GET/PUT `/api/admin/skill-curation`;原技能列表仅在策展开启时应用新增分类筛选,响应结构不变。见 [接口与行为](./skill-curation)。 diff --git a/docs/content/docs/backend/skill-curation.mdx b/docs/content/docs/backend/skill-curation.mdx new file mode 100644 index 000000000..d283b6952 --- /dev/null +++ b/docs/content/docs/backend/skill-curation.mdx @@ -0,0 +1,33 @@ +--- +title: 平台技能策展 +description: 可选的后台子分类维护 +--- + +平台策展模式在“后台 → 技能分类”中开启,默认关闭。关闭时原有 `/skills` JSON、固定五类、个人技能库分类保持不变,新增筛选参数被忽略。分类设置可以在开启前准备。 + +管理员可以新增、改名、排序、停用子分类,并给公开且启用的技能设置同根的多个子分类。停用不删除指派或个人加入状态,恢复后重新生效。技能 tag 改变时旧跨根指派不再生效;删除技能同时清理指派。无有效指派的技能进入“未细分”。 + +## API + +- `GET /api/skills/curation`:登录后读取分类及公开有效技能指派;关闭时返回空集合。 +- `GET /api/admin/skill-curation`:管理员读取设置、revision、含停用的子分类及公开技能指派。 +- `PUT /api/admin/skill-curation`:管理员提交 `expectedRevision`,及 `enabled`、完整 `category`、完整 `assignment` 三者之一。category 含 id(新建为空)、rootTag、name、sortOrder、enabled。assignment 含 skillId、categoryIds;空数组清除,null 拒绝。 +- `GET /api/skills` 新增 `platformCategoryId` 或 `platformUncategorized=true`,仅开启时生效;与原有 scope、搜索、排序、个人分类组合,分页前筛选,不改变响应字段。 + +写请求上限 64 KiB、单技能最多 32 类;拒绝未知字段、重复指派、跨根、私人技能、过期版本。409 后重新加载核对修改。管理员身份在业务层和事务内检查,revision CAS、修改和 AdminAuditEvent 同事务。审计只记录分类元数据,不记录技能正文。分类不是使用技能的授权。 + +## 数据库 + +迁移 46 `skill_curation`,checksum `sha256:skill-curation-v46-20261005`: + +| 表 | 用途 | +| --- | --- | +| skill_curation_settings | 单例 id=1,enabled 默认 false,revision 用于并发写入 | +| skill_curation_categories | 稳定 id、root_tag、名称及规范化名称、sort_order、enabled;同根名称唯一 | +| skill_curation_assignments | skill_id + category_id 联合主键 | + +迁移只建表及默认关闭的设置,不导入技能或分类内容。44、45 由独立变更保留;完整迁移程序支持缺号补跑。停用开关不是数据库降版;回退旧程序需要匹配的数据库备份。 + +## 待验证 + +SQLite 及 HTTP recorder 可在无网络环境验证。PostgreSQL、前端构建、浏览器中的明暗主题、键盘操作和分页交互需要单独验证。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 619245263..65011f920 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -448,3 +448,7 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph ## 画布生成工具(待本地验收) 画布支持风格、运镜、九宫格提示词工具及自定义特效工具,提供收藏和私有自定义工具。工具仅加入提示词或创建待生成子节点,选择工具不自动发起生成。画布左侧提供主页、折叠、节点、资产、任务和历史菜单,不再提供独立工具菜单,展开面板沿用主分支主题。资产支持分类、搜索、分页和直接插入,复用现有素材服务及插入合同;任务和历史独立,历史明确展示最近 30 条。图片节点直接展示风格和预设,不再展示提示词模板入口;其他节点的提示词模板保持不变。 + +## 平台技能策展 + +后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 110851138..f867834fc 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -2915,3 +2915,7 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客 仍待真实环境验收: - [ ] 在管理员后台存储资源页分别输入完整/部分用户名、昵称、邮箱和用户 ID,确认返回同一用户资源;再叠加类型、状态和存储位置筛选,确认分页与清空筛选正常。 + +## 平台技能策展 + +SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。 diff --git a/web/src/components/canvas/canvas-agent-skill-library-modal.tsx b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx index 40777b05d..c4ffd3cba 100644 --- a/web/src/components/canvas/canvas-agent-skill-library-modal.tsx +++ b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx @@ -5,10 +5,14 @@ import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react import { AppModal } from "@/components/ui/product/app-modal"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills"; +import { SkillCurationBrowser, matchesCuration, type useSkillCuration } from "@/components/skills/skill-curation-browser"; type SkillLibraryTab = "enabled" | "installed" | "market"; type CanvasAgentSkillLibraryModalProps = { + curationState: ReturnType; + platformCategory: string; + onPlatformCategoryChange: (value: string) => void; open: boolean; theme: CanvasTheme; installedSkills: Skill[]; @@ -29,6 +33,7 @@ type CanvasAgentSkillLibraryModalProps = { }; export function CanvasAgentSkillLibraryModal({ + curationState, platformCategory, onPlatformCategoryChange, open, theme, installedSkills, @@ -84,6 +89,7 @@ export function CanvasAgentSkillLibraryModal({ const keyword = search.trim().toLocaleLowerCase("zh-CN"); const source = tab === "market" ? marketSkills : librarySource; return source.filter((skill) => { + if (!matchesCuration(skill, curationState.curation, platformCategory)) return false; if (tab === "market" && category !== "all" && skill.tag !== category) return false; if (tab !== "market") { if (libraryCategoryId === "__uncategorized__" && skill.libraryCategoryId) return false; @@ -94,7 +100,7 @@ export function CanvasAgentSkillLibraryModal({ .toLocaleLowerCase("zh-CN") .includes(keyword); }); - }, [categories, category, libraryCategoryId, librarySource, marketSkills, search, tab]); + }, [categories, category, libraryCategoryId, librarySource, marketSkills, search, tab, curationState.curation, platformCategory]); useEffect(() => { const target = loadMoreRef.current; @@ -181,6 +187,7 @@ export function CanvasAgentSkillLibraryModal({ className="canvas-agent-skill-library-search" />