feat(skills): 技能管理 - 增加可选的平台子分类策展 (#693)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
16
backend/internal/app/skill_curation_bridge.go
Normal file
16
backend/internal/app/skill_curation_bridge.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
14
backend/internal/database/skill_curation.go
Normal file
14
backend/internal/database/skill_curation.go
Normal 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
|
||||
}
|
||||
104
backend/internal/database/skill_curation_test.go
Normal file
104
backend/internal/database/skill_curation_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
54
backend/internal/handler/skill_curation.go
Normal file
54
backend/internal/handler/skill_curation.go
Normal 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)
|
||||
})
|
||||
}
|
||||
62
backend/internal/handler/skill_curation_test.go
Normal file
62
backend/internal/handler/skill_curation_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
21
backend/internal/model/skill_curation.go
Normal file
21
backend/internal/model/skill_curation.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
114
backend/internal/repository/skill_curation.go
Normal file
114
backend/internal/repository/skill_curation.go
Normal 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`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
6
backend/internal/service/aliases_skill_curation.go
Normal file
6
backend/internal/service/aliases_skill_curation.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package service
|
||||
|
||||
import "infinite-canvas/backend/internal/app"
|
||||
|
||||
type SkillCuration = app.SkillCuration
|
||||
type SkillCurationUpdate = app.SkillCurationUpdate
|
||||
148
backend/internal/skills/skill_curation.go
Normal file
148
backend/internal/skills/skill_curation.go
Normal 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
|
||||
}
|
||||
166
backend/internal/skills/skill_curation_test.go
Normal file
166
backend/internal/skills/skill_curation_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 接入原有技能域。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
33
docs/content/docs/backend/skill-curation.mdx
Normal file
33
docs/content/docs/backend/skill-curation.mdx
Normal 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、前端构建、浏览器中的明暗主题、键盘操作和分页交互需要单独验证。
|
||||
@@ -448,3 +448,7 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph
|
||||
## 画布生成工具(待本地验收)
|
||||
|
||||
画布支持风格、运镜、九宫格提示词工具及自定义特效工具,提供收藏和私有自定义工具。工具仅加入提示词或创建待生成子节点,选择工具不自动发起生成。画布左侧提供主页、折叠、节点、资产、任务和历史菜单,不再提供独立工具菜单,展开面板沿用主分支主题。资产支持分类、搜索、分页和直接插入,复用现有素材服务及插入合同;任务和历史独立,历史明确展示最近 30 条。图片节点直接展示风格和预设,不再展示提示词模板入口;其他节点的提示词模板保持不变。
|
||||
|
||||
## 平台技能策展
|
||||
|
||||
后台可选开启子分类维护,市场和技能库支持子分类浏览;默认关闭,保留原行为。
|
||||
|
||||
@@ -2915,3 +2915,7 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
|
||||
仍待真实环境验收:
|
||||
|
||||
- [ ] 在管理员后台存储资源页分别输入完整/部分用户名、昵称、邮箱和用户 ID,确认返回同一用户资源;再叠加类型、状态和存储位置筛选,确认分页与清空筛选正常。
|
||||
|
||||
## 平台技能策展
|
||||
|
||||
SQLite 与 HTTP recorder 已验证;PostgreSQL 16/17、前端构建及浏览器中的开关、分类维护、市场/技能库筛选、分页与明暗主题待验证。
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
47
web/src/components/skills/skill-curation-browser.tsx
Normal file
47
web/src/components/skills/skill-curation-browser.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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" /> },
|
||||
|
||||
59
web/src/pages/admin/skill-curation-page.tsx
Normal file
59
web/src/pages/admin/skill-curation-page.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
9
web/src/services/api/skill-curation.ts
Normal file
9
web/src/services/api/skill-curation.ts
Normal 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 });
|
||||
}
|
||||
@@ -109,6 +109,8 @@ export type SkillList = {
|
||||
};
|
||||
|
||||
export type ListSkillsInput = {
|
||||
platformCategoryId?: string;
|
||||
platformUncategorized?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
scope?: SkillScope;
|
||||
|
||||
20
web/test/skill-curation.test.ts
Normal file
20
web/test/skill-curation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user