From ec81a1b92bc4d90346c8cb7c5c1d8755182bd833 Mon Sep 17 00:00:00 2001 From: Wyatt_K Date: Wed, 23 Sep 2026 16:01:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E5=9C=BA=E6=99=AF=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=20-=20=E6=8F=90=E4=BE=9B=E5=85=AC=E5=BC=80=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E7=9B=AE=E5=BD=95=E5=B9=B6=E6=A0=A1=E9=AA=8C=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E6=8A=80=E8=83=BD=E5=BC=95=E7=94=A8=20(#603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/skills_bridge.go | 10 ++ backend/internal/handler/api_test.go | 7 + backend/internal/handler/openapi.yaml | 9 ++ backend/internal/handler/skills.go | 10 ++ backend/internal/service/aliases_types.go | 1 + backend/internal/skills/seed/presets.json | 87 ++++++++++++ backend/internal/skills/skills_presets.go | 131 ++++++++++++++++++ .../internal/skills/skills_presets_test.go | 109 +++++++++++++++ 8 files changed, 364 insertions(+) create mode 100644 backend/internal/skills/seed/presets.json create mode 100644 backend/internal/skills/skills_presets.go create mode 100644 backend/internal/skills/skills_presets_test.go diff --git a/backend/internal/app/skills_bridge.go b/backend/internal/app/skills_bridge.go index 46b4667d8..1a50398e7 100644 --- a/backend/internal/app/skills_bridge.go +++ b/backend/internal/app/skills_bridge.go @@ -24,6 +24,7 @@ type ( SkillPackageBundleFile = skills.SkillPackageBundleFile SkillPackageBundle = skills.SkillPackageBundle SkillFileSearchResult = skills.SkillFileSearchResult + SkillPreset = skills.SkillPreset ) func (s *Service) skillDomain() *skills.Service { @@ -44,6 +45,11 @@ func (s *Service) AddedSkills(userID string) ([]SkillItem, error) { return s.skillDomain().AddedSkills(userID) } +// SkillPresets 返回场景预设目录(只读;数据随技能种子发布,无需用户上下文)。 +func (s *Service) SkillPresets() ([]SkillPreset, error) { + return s.skillDomain().SkillPresets() +} + func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) { return s.skillDomain().SkillDetail(userID, id) } @@ -69,6 +75,10 @@ func (s *Service) SetSkillLiked(userID string, id string, liked bool) (*SkillIte } func (s *Service) EnsureBuiltinSkills() error { + // 在同步种子之前校验同版本的预设,避免服务启动后才在公开目录暴露坏引用。 + if _, err := s.SkillPresets(); err != nil { + return err + } return s.skillDomain().EnsureBuiltinSkills() } diff --git a/backend/internal/handler/api_test.go b/backend/internal/handler/api_test.go index b0fc516a9..6925e98ed 100644 --- a/backend/internal/handler/api_test.go +++ b/backend/internal/handler/api_test.go @@ -22,6 +22,7 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) { "POST /api/tasks": false, "POST /api/tasks/:id/recover-media": false, "GET /api/resources": false, + "GET /api/skills/presets": false, } for _, route := range router.Routes() { key := route.Method + " " + route.Path @@ -40,4 +41,10 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) { if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "openapi: 3.0.3") || !strings.Contains(recorder.Body.String(), "url: /api") { t.Fatalf("openapi.yaml status=%d body=%s", recorder.Code, recorder.Body.String()) } + + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/skills/presets", nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"presets"`) { + t.Fatalf("skills presets status=%d body=%s", recorder.Code, recorder.Body.String()) + } } diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 860ccc02f..754d26af6 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -114,6 +114,15 @@ paths: "200": {description: 标准信封 data 为完整工具} "400": {description: 参数无效或预览资源不符合要求} "401": {description: 未登录} + /skills/presets: + get: + tags: [canvas] + security: [] + summary: 获取公开场景技能预设目录 + responses: + "200": + description: 标准信封 data 为 presets 数组;预设只引用公开且启用的种子技能 + "500": {$ref: "#/components/responses/Error"} /tools/{id}: parameters: - {in: path, name: id, required: true, schema: {type: integer, format: int64, minimum: 1}} diff --git a/backend/internal/handler/skills.go b/backend/internal/handler/skills.go index 2a9e9c1fd..346865f7a 100644 --- a/backend/internal/handler/skills.go +++ b/backend/internal/handler/skills.go @@ -91,6 +91,16 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, result) }) + r.GET("/skills/presets", func(c *gin.Context) { + // 场景预设目录:公开只读的市场元数据(与 /openapi.yaml 同模式,无用户数据)。 + presets, err := svc.SkillPresets() + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"presets": presets}) + }) + r.GET("/skills/added", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 57042b836..bb9239964 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -283,6 +283,7 @@ type ( SkillPackageBundleFile = app.SkillPackageBundleFile SkillPackageFileContent = app.SkillPackageFileContent SkillPackageFileItem = app.SkillPackageFileItem + SkillPreset = app.SkillPreset SkillShowcaseMedia = app.SkillShowcaseMedia StorageMigrationSummary = app.StorageMigrationSummary StyleProfileFavoriteRequest = app.StyleProfileFavoriteRequest diff --git a/backend/internal/skills/seed/presets.json b/backend/internal/skills/seed/presets.json new file mode 100644 index 000000000..c80f08d52 --- /dev/null +++ b/backend/internal/skills/seed/presets.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "updated": "2026-09-22", + "note": "场景预设内容层(平台层 RFC 见 项目/场景预设平台层-RFC-2026-09-22.md)。v1 只引用已进种子市场的技能(33 官方 + 35 judian 域包),未上架的 56 个 singles 不引用——避免用户一键挂载时遇到装不上的技能。skillIds 与上游 backend/internal/skills/seed/skills.json 对齐,变更前必须跑 presets_check.py 对账。", + "presets": [ + { + "presetId": "short-drama-starter", + "name": "短剧爆款起步", + "scene": "drama", + "skillIds": ["16000000000081", "16000000000077", "16000000000091", "14811816970508"], + "rationale": "新手第一站:实战手册管钩子/反转/爽点方法论,一镜到底流水线管单集秒级节奏,模型路由先答该用哪个模型,官方叙事分镜把节奏落地成表。一个预设覆盖从想法到分镜表。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "singles 上架后,用三张 E1 实测卡(短剧开篇八法与付费点/爽点与钩子设计/反转结尾写法)替换对应域包位,粒度更轻;若 #590 显示用户反复跨对话重挂同一预设,再评估项目级默认(需 schema)。" + }, + { + "presetId": "short-drama-pilot", + "name": "短剧立项与大纲", + "scene": "drama", + "skillIds": ["16000000000084", "16000000000073", "16000000000079", "16000000000075", "14811816983052"], + "rationale": "从零立项一部短剧:结构引擎给坐标系,角色工坊立人物,系列弧光规划跨集,对白工坊磨台词;官方故事开发兜底生产。适合还没动笔的阶段。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "视 #590 的 topCards 数据,把使用率低的域包换成同场景高命中单卡(待 singles 上架)。" + }, + { + "presetId": "ad-full-chain", + "name": "广告全链路", + "scene": "ecommerce", + "skillIds": ["16000000000105", "16000000000098", "16000000000097", "16000000000095", "14519800321292"], + "rationale": "调研→概念→脚本→分镜一次配齐:全链路打法是主战,消费心理给卖点依据,文案四步打磨标题与长文案,视觉提示词工程管画面;官方波普广告导演做最终生产。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "电商场景模板包(分镜节点+batch-table)落地后,此预设可挂到模板上一并分发。" + }, + { + "presetId": "h3-video", + "name": "H3 视频出片", + "scene": "creative", + "skillIds": ["16000000000090", "16000000000091"], + "rationale": "即梦 H3 出片最小组合:提示词套件含六段式/首尾帧衔接/资产绑定,模型路由答该用哪个模型族。两个就够,不凑数。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "若 H3 参考资产绑定类问题在 #590 的 readFailures 里占比高,把 singles 的 H3 参考资产绑定卡(E4 待测)纳入替换。" + }, + { + "presetId": "seedance-video", + "name": "Seedance 生视频", + "scene": "creative", + "skillIds": ["16000000000093", "16000000000092"], + "rationale": "Seedance 出片:提示词工程给八块公式与参考图语法,2.5 导演工艺补模式选择与多镜连续性。单镜头用前者,多镜连续再加后者。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "Seedance 八块公式 single 已通过 E1 实测(2026-09-22),待 singles 上架后替换域包位。" + }, + { + "presetId": "ecom-image", + "name": "电商大图与系列图", + "scene": "ecommerce", + "skillIds": ["16000000000095", "16000000000089", "16000000000105", "14811816982028"], + "rationale": "产品图/系列图:视觉提示词工程管四层结构与控色,风格库给模板与陷阱,全链路打法深读产品描述与商品图模式卡;官方系列套图生成做批量生产。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "singles 的生图五槽去油腻模板卡(E4 待测)上架后可作轻量替换项。" + }, + { + "presetId": "ai-performer", + "name": "AI 角色表演", + "scene": "drama", + "skillIds": ["16000000000086", "16000000000073", "16000000000096", "14889899552012"], + "rationale": "IP 短片/数字人:表演实验室管表情/肢体/情绪节拍提示词,角色工坊保人设一致,视觉基调定全片气质;官方微表情导演做视频生产。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "AI 角色表演分层写法 single 待 E1 实测后(下批灯塔卡候选),可作替换位。" + }, + { + "presetId": "brand-positioning", + "name": "品牌定位与策略", + "scene": "ecommerce", + "skillIds": ["16000000000102", "16000000000104", "16000000000099", "16000000000106"], + "rationale": "不写片不拍片的日子用这组:定位策略找心智空格,故事营销把目的讲成故事,决策模型格森做判断,STEPPS 设计传播。纯思考层,无生产技能。", + "source": "hand-curated", + "evidence": "E4", + "upgrade": "与官方技能无重叠冲突,可长期稳定;若定位类 singles(定位心智图等)上架,可拆出轻量版。" + } + ] +} diff --git a/backend/internal/skills/skills_presets.go b/backend/internal/skills/skills_presets.go new file mode 100644 index 000000000..30bc04eed --- /dev/null +++ b/backend/internal/skills/skills_presets.go @@ -0,0 +1,131 @@ +package skills + +import ( + _ "embed" + "encoding/json" + "fmt" + "regexp" + "strings" +) + +//go:embed seed/presets.json +var builtinSkillPresetsJSON []byte + +// 场景预设(Scene Presets):把「说人话的场景」映射到一组已启用技能, +// 让用户不必在 120+ 个技能里盲挑。v1 为只读目录,与技能种子同源发布。 +// +// 分层契约:内容层(组合知识)在 judian-skills 的 presets.json 独立演进, +// 本文件只做加载、校验与分发;preset 引用一律用 skillId(编号一编永不变), +// 因此技能改名不影响预设有效性。 +type SkillPreset struct { + PresetID string `json:"presetId"` + Name string `json:"name"` + Scene string `json:"scene"` + SkillIDs []string `json:"skillIds"` + Rationale string `json:"rationale"` + Source string `json:"source"` + Evidence string `json:"evidence"` + Upgrade string `json:"upgrade"` +} + +type skillPresetsFile struct { + Version int `json:"version"` + Updated string `json:"updated"` + Note string `json:"note"` + Presets []SkillPreset `json:"presets"` +} + +var skillPresetIDPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +var skillPresetScenes = map[string]struct{}{ + "drama": {}, "creative": {}, "ecommerce": {}, "social": {}, "others": {}, +} + +var skillPresetEvidence = map[string]struct{}{ + "E1": {}, "E2": {}, "E3": {}, "E4": {}, "E5": {}, +} + +// SkillPresets 返回校验合格的场景预设目录;数据非法时返回错误(启动即失败好过线上坏数据)。 +func (s *Service) SkillPresets() ([]SkillPreset, error) { + var file skillPresetsFile + if err := json.Unmarshal(builtinSkillPresetsJSON, &file); err != nil { + return nil, fmt.Errorf("解析场景预设失败: %w", err) + } + if file.Version != 1 { + return nil, fmt.Errorf("场景预设版本必须为 1,当前 %d", file.Version) + } + if len(file.Presets) == 0 { + return nil, fmt.Errorf("场景预设不能为空") + } + seeded := make(map[string]struct{}, 128) + seedIDs, err := builtinSeedSkillIDs() + if err != nil { + return nil, fmt.Errorf("校验场景预设引用失败: %w", err) + } + for _, definition := range seedIDs { + seeded[definition] = struct{}{} + } + seen := make(map[string]struct{}, len(file.Presets)) + for _, preset := range file.Presets { + id := strings.TrimSpace(preset.PresetID) + if !skillPresetIDPattern.MatchString(id) { + return nil, fmt.Errorf("场景预设 ID 非法: %q", preset.PresetID) + } + if _, dup := seen[id]; dup { + return nil, fmt.Errorf("场景预设 ID 重复: %s", id) + } + seen[id] = struct{}{} + if strings.TrimSpace(preset.Name) == "" { + return nil, fmt.Errorf("场景预设 %s 缺名称", id) + } + if _, ok := skillPresetScenes[preset.Scene]; !ok { + return nil, fmt.Errorf("场景预设 %s 的分类非法: %q", id, preset.Scene) + } + if len(preset.SkillIDs) == 0 || len(preset.SkillIDs) > 8 { + return nil, fmt.Errorf("场景预设 %s 的技能数 %d 超出 1-8(每轮激活上限)", id, len(preset.SkillIDs)) + } + presetSeen := make(map[string]struct{}, len(preset.SkillIDs)) + for _, skillID := range preset.SkillIDs { + if _, dup := presetSeen[skillID]; dup { + return nil, fmt.Errorf("场景预设 %s 的技能重复: %s", id, skillID) + } + presetSeen[skillID] = struct{}{} + // v1 硬约束:只允许引用种子市场已上架技能,避免用户一键挂载时遇到装不上的技能。 + if _, ok := seeded[skillID]; !ok { + return nil, fmt.Errorf("场景预设 %s 引用了未上架技能: %s", id, skillID) + } + } + if preset.Source != "hand-curated" { + return nil, fmt.Errorf("场景预设 %s 的 source 必须为 hand-curated(v1 禁止遥测驱动)", id) + } + if _, ok := skillPresetEvidence[preset.Evidence]; !ok { + return nil, fmt.Errorf("场景预设 %s 的证据等级非法: %q", id, preset.Evidence) + } + if rationale := strings.TrimSpace(preset.Rationale); rationale == "" || len([]rune(rationale)) > 200 { + return nil, fmt.Errorf("场景预设 %s 的推荐理由为空或超过 200 字", id) + } + } + return file.Presets, nil +} + +// builtinSeedSkillIDs 从内置种子清单提取全部 skillId,供预设引用对账。 +func builtinSeedSkillIDs() ([]string, error) { + var definitions []builtinSkillDefinition + if err := json.Unmarshal(builtinSkillsJSON, &definitions); err != nil { + return nil, fmt.Errorf("解析内置技能失败: %w", err) + } + definitions = append(definitions, builtinImageEditingSkillDefinitions()...) + ids := make([]string, 0, len(definitions)) + for _, definition := range definitions { + if definition.Status != skillStatusEnabled || definition.IsPrivate { + continue + } + if id := strings.TrimSpace(definition.SkillID); id != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, fmt.Errorf("内置种子技能不能为空") + } + return ids, nil +} diff --git a/backend/internal/skills/skills_presets_test.go b/backend/internal/skills/skills_presets_test.go new file mode 100644 index 000000000..008e3faf7 --- /dev/null +++ b/backend/internal/skills/skills_presets_test.go @@ -0,0 +1,109 @@ +package skills + +import ( + "strings" + "testing" +) + +func TestSkillPresetsCatalogIsValid(t *testing.T) { + svc := New(nil, "", nil) + presets, err := svc.SkillPresets() + if err != nil { + t.Fatal(err) + } + if len(presets) == 0 { + t.Fatal("场景预设目录不能为空") + } + seeded := make(map[string]struct{}, 128) + seedIDs, err := builtinSeedSkillIDs() + if err != nil { + t.Fatal(err) + } + for _, id := range seedIDs { + seeded[id] = struct{}{} + } + if len(seeded) == 0 { + t.Fatal("内置种子技能为空,对账失去意义") + } + seen := map[string]struct{}{} + for _, preset := range presets { + if _, dup := seen[preset.PresetID]; dup { + t.Fatalf("presetId 重复: %s", preset.PresetID) + } + seen[preset.PresetID] = struct{}{} + if len(preset.SkillIDs) == 0 || len(preset.SkillIDs) > 8 { + t.Fatalf("%s 技能数 %d 超出 1-8", preset.PresetID, len(preset.SkillIDs)) + } + for _, skillID := range preset.SkillIDs { + // v1 硬约束:preset 只允许引用种子市场已上架技能。 + if _, ok := seeded[skillID]; !ok { + t.Fatalf("%s 引用未上架技能 %s", preset.PresetID, skillID) + } + } + if preset.Rationale == "" { + t.Fatalf("%s 缺推荐理由", preset.PresetID) + } + } +} + +func TestSkillPresetsRejectUnseededSkillReference(t *testing.T) { + // 防回归:preset 引用一个不在种子清单里的 skillId 时必须被校验拦下, + // 否则用户一键挂载会遇到装不上的技能(v1 明确禁止)。 + original := builtinSkillPresetsJSON + defer func() { builtinSkillPresetsJSON = original }() + builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"bad","name":"坏预设","scene":"drama","skillIds":["16000000000099","99999999999999"],"rationale":"引用了不存在的技能","source":"hand-curated","evidence":"E4","upgrade":""}]}`) + svc := New(nil, "", nil) + if _, err := svc.SkillPresets(); err == nil || !strings.Contains(err.Error(), "未上架技能") { + t.Fatalf("应拒绝未上架技能引用,实际: %v", err) + } +} + +func TestSkillPresetsRejectInvalidSkillSeed(t *testing.T) { + original := builtinSkillsJSON + defer func() { builtinSkillsJSON = original }() + builtinSkillsJSON = []byte(`not-json`) + if _, err := New(nil, "", nil).SkillPresets(); err == nil || !strings.Contains(err.Error(), "解析内置技能失败") { + t.Fatalf("应明确报告内置技能种子损坏,实际: %v", err) + } +} + +func TestSkillPresetsIgnoreDisabledOrPrivateSeeds(t *testing.T) { + original := builtinSkillsJSON + defer func() { builtinSkillsJSON = original }() + builtinSkillsJSON = []byte(`[ + {"skill_id":"enabled-public","status":1,"is_private":false}, + {"skill_id":"disabled-public","status":0,"is_private":false}, + {"skill_id":"enabled-private","status":1,"is_private":true} + ]`) + + originalPresets := builtinSkillPresetsJSON + defer func() { builtinSkillPresetsJSON = originalPresets }() + builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"ok","name":"可用预设","scene":"drama","skillIds":["enabled-public"],"rationale":"只引用公开启用技能","source":"hand-curated","evidence":"E4","upgrade":""}]}`) + + if _, err := New(nil, "", nil).SkillPresets(); err != nil { + t.Fatalf("公开启用种子应可被预设引用: %v", err) + } + + for _, skillID := range []string{"disabled-public", "enabled-private"} { + builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"bad","name":"非法预设","scene":"drama","skillIds":["` + skillID + `"],"rationale":"不应通过校验","source":"hand-curated","evidence":"E4","upgrade":""}]}`) + if _, err := New(nil, "", nil).SkillPresets(); err == nil || !strings.Contains(err.Error(), "未上架技能") { + t.Fatalf("%s 不应被视为已上架,实际: %v", skillID, err) + } + } +} + +func TestSkillPresetsRejectOverBudgetAndTelemetrySource(t *testing.T) { + original := builtinSkillPresetsJSON + defer func() { builtinSkillPresetsJSON = original }() + // 超过 8 个激活位必须被拒(影策每轮最多激活 8 个技能)。 + builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"big","name":"超大预设","scene":"drama","skillIds":["a","b","c","d","e","f","g","h","i"],"rationale":"超预算","source":"hand-curated","evidence":"E4","upgrade":""}]}`) + svc := New(nil, "", nil) + if _, err := svc.SkillPresets(); err == nil || !strings.Contains(err.Error(), "1-8") { + t.Fatalf("应拒绝超 8 技能预设,实际: %v", err) + } + // v1 只允许手工策展:遥测驱动的预设要等 #590 有数据后才放开。 + builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"tele","name":"遥测预设","scene":"drama","skillIds":["16000000000099"],"rationale":"数据驱动","source":"telemetry","evidence":"E4","upgrade":""}]}`) + if _, err := svc.SkillPresets(); err == nil || !strings.Contains(err.Error(), "hand-curated") { + t.Fatalf("应拒绝非手工策展来源,实际: %v", err) + } +}