feat(skills): 技能管理 - 增加可选的平台子分类策展 (#693)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:24:21 +08:00
committed by GitHub
parent f6003c1c88
commit fb4af9bd34
32 changed files with 981 additions and 24 deletions

3
.gitignore vendored
View File

@@ -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/
.zocde/

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
})
}

View File

@@ -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())
}
}
}

View File

@@ -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,

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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`

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
package service
import "infinite-canvas/backend/internal/app"
type SkillCuration = app.SkillCuration
type SkillCurationUpdate = app.SkillCurationUpdate

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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,

View File

@@ -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)。

View File

@@ -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 接入原有技能域。

View File

@@ -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)。

View File

@@ -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、前端构建、浏览器中的明暗主题、键盘操作和分页交互需要单独验证。

View File

@@ -448,3 +448,7 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
## 画布生成工具(待本地验收)
画布支持风格、运镜、九宫格提示词工具及自定义特效工具,提供收藏和私有自定义工具。工具仅加入提示词或创建待生成子节点,选择工具不自动发起生成。画布左侧提供主页、折叠、节点、资产、任务和历史菜单,不再提供独立工具菜单,展开面板沿用主分支主题。资产支持分类、搜索、分页和直接插入,复用现有素材服务及插入合同;任务和历史独立,历史明确展示最近 30 条。图片节点直接展示风格和预设,不再展示提示词模板入口;其他节点的提示词模板保持不变。
## 平台技能策展
后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。

View File

@@ -2915,3 +2915,7 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
仍待真实环境验收:
- [ ] 在管理员后台存储资源页分别输入完整/部分用户名、昵称、邮箱和用户 ID,确认返回同一用户资源;再叠加类型、状态和存储位置筛选,确认分页与清空筛选正常。
## 平台技能策展
SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。

View File

@@ -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<typeof useSkillCuration>;
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"
/>
<nav className="canvas-agent-skill-library-categories thin-scrollbar" aria-label="技能视图与分类">
<SkillCurationBrowser data={curationState.curation} value={platformCategory} onChange={onPlatformCategoryChange} error={curationState.error} retry={curationState.retry} />
<div className="canvas-agent-skill-library-category-group">
<div className="canvas-agent-skill-library-category-heading">我的技能分类</div>
<SkillTab active={tab !== "market" && libraryCategoryId === "all"} label="全部分类" count={librarySource.length} onClick={() => selectLibraryCategory("all")} />

View File

@@ -52,6 +52,7 @@ import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { AGENT_SCENE_DEFS, AgentChatComposer, parseCloudAgentFormAnswer, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, type AgentSceneBucket, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui";
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
import { CanvasCloudAgentSettings, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
import "./canvas-cloud-agent.css";
@@ -109,6 +110,11 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const [libraryCategories, setLibraryCategories] = useState<SkillLibraryCategory[]>([]);
const [skillTag, setSkillTag] = useState("all");
const [skillsOpen, setSkillsOpen] = useState(false);
const curationState = useSkillCuration(skillsOpen);
const [platformCategory, setPlatformCategory] = useState("");
const skillBrowseKey = JSON.stringify([userId, skillTag, debouncedSkillSearch, platformCategory, curationState.curation?.revision, curationState.curation?.enabled]);
const skillBrowseKeyRef = useRef(skillBrowseKey);
skillBrowseKeyRef.current = skillBrowseKey;
const [busy, setBusy] = useState(false);
const [approvalSubmitting, setApprovalSubmitting] = useState(false);
const [exporting, setExporting] = useState(false);
@@ -386,6 +392,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
setSkillsLoading(true);
void listSkills({
scope: "public",
...curationQuery(curationState.curation, platformCategory),
search: debouncedSkillSearch || undefined,
tag: skillsOpen && skillTag !== "all" ? skillTag : undefined,
pageSize: 20,
@@ -408,7 +415,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
return () => {
active = false;
};
}, [view, skillsOpen, debouncedSkillSearch, skillTag]);
}, [view, skillsOpen, debouncedSkillSearch, skillTag, curationState.curation, platformCategory]);
useEffect(() => {
if (view !== "settings" && !skillsOpen) return;
@@ -426,25 +433,28 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
}, [skillsOpen, userId, view]);
const loadMoreSkills = async () => {
const requestKey = skillBrowseKeyRef.current;
if (skillsLoading || skillPageRequestRef.current || !skillHasMore || skillSearch.trim() !== debouncedSkillSearch) return;
skillPageRequestRef.current = true;
setSkillsLoading(true);
try {
const result = await listSkills({
scope: "public",
...curationQuery(curationState.curation, platformCategory),
search: debouncedSkillSearch || undefined,
tag: skillsOpen && skillTag !== "all" ? skillTag : undefined,
page: skillPage + 1,
pageSize: 20,
sort: "popular",
});
if (requestKey !== skillBrowseKeyRef.current) return;
setMarketSkills((current) => [...current, ...result.skills.filter((skill) => !current.some((item) => item.skillId === skill.skillId))]);
setSkillCategories(result.categories);
setSkillPage(result.page);
setSkillHasMore(result.hasMore);
} finally {
skillPageRequestRef.current = false;
setSkillsLoading(false);
if (requestKey === skillBrowseKeyRef.current) setSkillsLoading(false);
}
};
@@ -1059,6 +1069,9 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
) : null}
</AnimatePresence>
<CanvasAgentSkillLibraryModal
curationState={curationState}
platformCategory={platformCategory}
onPlatformCategoryChange={(value) => { setPlatformCategory(value); setSkillTag("all"); }}
open={skillsOpen}
theme={theme}
installedSkills={installedSkills}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { Alert, Button, Select, Space } from "antd";
import { getActiveUserScope } from "@/lib/user-scope";
import { getSkillCuration, type SkillCuration } from "@/services/api/skill-curation";
import type { Skill } from "@/services/api/skills";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
export function curationQuery(data: SkillCuration | null, value: string) {
if (!data?.enabled || !value) return {};
return value === "__uncategorized__" ? { platformUncategorized: true } : { platformCategoryId: value };
}
export function useSkillCuration(active = true) {
const scope = getActiveUserScope();
const [result, setResult] = useState<{ scope: string; data: SkillCuration } | null>(null);
const [error, setError] = useState("");
const [reload, setReload] = useState(0);
useEffect(() => {
if (!active) return;
let cancelled = false;
setError("");
getSkillCuration().then((data) => { if (!cancelled) setResult({ scope, data }); }).catch(() => { if (!cancelled) { setResult(null); setError("分类暂不可用,请重试"); } });
const refresh = () => setReload((value) => value + 1);
window.addEventListener("focus", refresh);
window.addEventListener("canvas-skills-changed", refresh);
return () => { cancelled = true; window.removeEventListener("focus", refresh); window.removeEventListener("canvas-skills-changed", refresh); };
}, [active, scope, reload]);
return { curation: result?.scope === scope ? result.data : null, error, retry: () => setReload((value) => value + 1) };
}
export function matchesCuration(skill: Skill, data: SkillCuration | null, category: string) {
if (!data?.enabled || !category) return true;
const ids = data.assignments.filter((item) => item.skillId === skill.skillId).map((item) => item.categoryId);
return category === "__uncategorized__" ? ids.length === 0 : ids.includes(category);
}
export function SkillCurationBrowser({ data, value, onChange, error, retry }: { data: SkillCuration | null; value: string; onChange: (value: string) => void; error: string; retry: () => void }) {
useEffect(() => {
if ((!data?.enabled || (value && value !== "__uncategorized__" && !data.categories.some((item) => item.id === value))) && value) onChange("");
}, [data, value, onChange]);
if (error) return <Alert type="warning" title={error} action={<Button onClick={retry}>重试</Button>} />;
if (!data?.enabled) return null;
return <Space wrap><span>平台分类</span><Select aria-label="平台子分类" value={value} onChange={onChange} popupMatchSelectWidth={false} options={[
{ value: "", label: "全部子分类" }, { value: "__uncategorized__", label: "未细分" },
...data.categories.map((item) => ({ value: item.id, label: `${fallbackSkillCategories.find((root) => root.value === item.rootTag)?.label || item.rootTag} / ${item.name}` })),
]} /></Space>;
}

View File

@@ -96,6 +96,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
label: "平台配置",
items: [
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
{ path: "/admin/skill-curation", label: "技能分类", description: "平台策展与分类维护", icon: <PlugZap className="size-4" /> },
{ path: "/admin/settings/appearance", label: "站点及外观", description: "品牌、SEO、备案与皮肤", icon: <Palette className="size-4" /> },
{ path: "/admin/settings/features", label: "功能开放", description: "工作台、插件与模型能力", icon: <ToggleLeft className="size-4" /> },
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd";
import { AdminPageFrame } from "./components/admin-shell";
import { AdminDataTable } from "./components/admin-ui";
import { AdminSwitch } from "./ui/controls";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration } from "@/services/api/skill-curation";
import { listSkills, type Skill } from "@/services/api/skills";
export default function SkillCurationPage() {
const { message } = App.useApp();
const [data, setData] = useState<SkillCuration | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [category, setCategory] = useState<CurationCategory>({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true });
const [search, setSearch] = useState("");
const [skills, setSkills] = useState<Skill[]>([]);
const [skillId, setSkillId] = useState("");
const [categoryIds, setCategoryIds] = useState<string[]>([]);
const reload = async () => { try { setData(await getSkillCuration(true)); setError(""); } catch (e) { setError(String(e)); } };
useEffect(() => { void reload(); }, []);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => { listSkills({ scope: "public", search, pageSize: 50 }).then((result) => { if (!cancelled) setSkills(result.skills); }).catch((e) => { if (!cancelled) setError(String(e)); }); }, 250);
return () => { cancelled = true; clearTimeout(timer); };
}, [search]);
const save = async (change: CurationChange) => {
if (!data) return;
setBusy(true);
try { setData(await updateSkillCuration(data.revision, change)); setError(""); message.success("已保存"); window.dispatchEvent(new Event("canvas-skills-changed")); }
catch (e) { setError(`${String(e)};请重新加载后核对修改`); }
finally { setBusy(false); }
};
const selectedSkill = skills.find((skill) => skill.skillId === skillId);
return <AdminPageFrame title="技能分类" description="平台策展模式默认关闭;开启后使用后台维护的分类。" scroll actions={<Button onClick={() => void reload()}>重新加载</Button>}>
<Space orientation="vertical" className="w-full" size="large">
{error && <Alert type="error" title={error} />}
<Space><AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} /><span>平台策展模式</span></Space>
<AdminDataTable<CurationCategory> table={{ rowKey: "id", dataSource: data?.categories || [], pagination: false, loading: !data, columns: [
{ title: "一级分类", dataIndex: "rootTag" }, { title: "子分类", dataIndex: "name" }, { title: "排序", dataIndex: "sortOrder" },
{ title: "状态", render: (_, row) => row.enabled ? "启用" : "停用" }, { title: "操作", render: (_, row) => <Button onClick={() => setCategory({ ...row })}>编辑</Button> },
] }} />
<Space wrap>
<Select aria-label="所属一级分类" value={category.rootTag} disabled={Boolean(category.id)} options={fallbackSkillCategories} fieldNames={{ value: "value", label: "label" }} onChange={(rootTag) => setCategory({ ...category, rootTag })} />
<Input aria-label="分类名称" placeholder="分类名称" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
<InputNumber aria-label="排序" value={category.sortOrder} precision={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
<Button type="primary" loading={busy} disabled={!data || !category.name.trim()} onClick={() => void save({ category })}>{category.id ? "保存分类" : "新增子分类"}</Button>
<Button onClick={() => setCategory({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true })}>新建</Button>
</Space>
<Space wrap>
<Input aria-label="搜索公开技能" placeholder="搜索公开技能(最多50条)" value={search} onChange={(e) => setSearch(e.target.value)} />
<Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} />
<Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === selectedSkill?.tag).map((item) => ({ value: item.id, label: item.name }))} />
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds } })}>保存技能归类</Button>
</Space>
</Space>
</AdminPageFrame>;
}

View File

@@ -41,7 +41,11 @@ const sortOptions: { label: string; value: SkillSort }[] = [
{ label: "最近更新", value: "updated" },
];
import { SkillCurationBrowser, useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
export default function SkillsPage() {
const { curation, error: curationError, retry: retryCuration } = useSkillCuration();
const [platformCategory, setPlatformCategory] = useState("");
const { message, modal } = App.useApp();
const [scope, setScope] = useState<SkillScope>("public");
const [sort, setSort] = useState<SkillSort>("popular");
@@ -106,7 +110,7 @@ export default function SkillsPage() {
? { libraryUncategorized: true }
: libraryCategoryId !== "all" ? { libraryCategoryId } : {}
: {};
listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: !isLibraryScope && tag !== "all" ? tag : undefined, ...libraryFilter })
listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: !isLibraryScope && tag !== "all" ? tag : undefined, ...libraryFilter, ...curationQuery(curation, platformCategory) })
.then((result) => {
if (cancelled) return;
setSkills(result.skills);
@@ -126,7 +130,7 @@ export default function SkillsPage() {
return () => {
cancelled = true;
};
}, [debouncedSearch, isLibraryScope, libraryCategoryId, page, pageSize, reloadKey, scope, sort, tag]);
}, [debouncedSearch, isLibraryScope, libraryCategoryId, page, pageSize, reloadKey, scope, sort, tag, curation, platformCategory]);
useEffect(() => {
let cancelled = false;
@@ -370,6 +374,7 @@ export default function SkillsPage() {
<PageHeader title="技能库" description="把提示词、角色设定和创作方法,变成随时可用的能力。" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>} />
<div className="skills-browse-bar">
<SkillCurationBrowser data={curation} value={platformCategory} onChange={(value) => { setPlatformCategory(value); setTag("all"); setPage(1); }} error={curationError} retry={retryCuration} />
<div className="skills-navigation">
<div className="skills-tabs" ref={tabsRef} role="tablist" aria-label="技能库范围" onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;

View File

@@ -21,6 +21,7 @@ const AgentLessonsPage = lazy(() => import("@/pages/admin/admin-route-pages").th
const ChannelsPage = lazy(() => import("@/pages/admin/channels/channels-page"));
const LogicalModelsPage = lazy(() => import("@/pages/admin/logical-models/logical-models-page"));
const AdminPluginsPage = lazy(() => import("@/pages/admin/plugins/plugins-page"));
const SkillCurationPage = lazy(() => import("@/pages/admin/skill-curation-page"));
const AdminPaymentsPage = lazy(() => import("@/pages/admin/payments/payments-page"));
const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page"));
const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page"));
@@ -188,6 +189,7 @@ export const router = createBrowserRouter([
{ path: "channels", element: <ChannelsPage /> },
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled"><LogicalModelsPage /></RequireFeature> },
{ path: "plugins", element: <AdminPluginsPage /> },
{ path: "skill-curation", element: <SkillCurationPage /> },
{ path: "payments", element: <AdminPaymentsPage /> },
{ path: "prompt-templates", element: <StoryboardPromptsPage /> },
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },

View File

@@ -0,0 +1,9 @@
import { http } from "./request";
export type CurationCategory = { id: string; rootTag: string; name: string; sortOrder: number; enabled: boolean };
export type SkillCuration = { enabled: boolean; revision: number; categories: CurationCategory[]; assignments: { skillId: string; categoryId: string }[] };
export type CurationChange = { enabled: boolean } | { category: CurationCategory } | { assignment: { skillId: string; categoryIds: string[] } };
export function getSkillCuration(admin = false) { return http.get<SkillCuration>(admin ? "/admin/skill-curation" : "/skills/curation"); }
export function updateSkillCuration(expectedRevision: number, change: CurationChange) {
return http.put<SkillCuration>("/admin/skill-curation", { expectedRevision, ...change });
}

View File

@@ -109,6 +109,8 @@ export type SkillList = {
};
export type ListSkillsInput = {
platformCategoryId?: string;
platformUncategorized?: boolean;
page?: number;
pageSize?: number;
scope?: SkillScope;

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { curationQuery, matchesCuration } from "../src/components/skills/skill-curation-browser";
import type { SkillCuration } from "../src/services/api/skill-curation";
import type { Skill } from "../src/services/api/skills";
describe("platform curation", () => {
const data: SkillCuration = { enabled: false, revision: 0, categories: [], assignments: [{ skillId: "one", categoryId: "child" }] };
const skill = { skillId: "one", tag: "drama" } as Skill;
test("disabled mode preserves existing requests and visible skills", () => {
expect(curationQuery(data, "child")).toEqual({});
expect(matchesCuration(skill, data, "missing")).toBe(true);
});
test("enabled mode filters by assignment and includes unassigned skills", () => {
const enabled = { ...data, enabled: true };
expect(curationQuery(enabled, "child")).toEqual({ platformCategoryId: "child" });
expect(matchesCuration(skill, enabled, "child")).toBe(true);
expect(matchesCuration(skill, enabled, "__uncategorized__")).toBe(false);
expect(matchesCuration({ ...skill, skillId: "two" }, enabled, "__uncategorized__")).toBe(true);
});
});