fix(skills): 内置技能 - 展示媒体兼容 snake_case 字段 (#687)

内置技能 frontmatter 的 showcaseMedia 使用 showcase_uri/showcase_url,加载器直接解码到 camelCase 的 SkillShowcaseMedia,封面地址在入库前就丢失;当前 71 个内置技能中有 66 个受影响,接口返回的展示媒体地址为空(读取端已有的 snake_case 兼容无法补回入库前丢失的值)。

- 内置加载器同时接受 snake_case 与 camelCase,统一输出 camelCase,数据库与公开 API 契约不变
- 同一字段两种写法的值不一致时记录技能 ID 与原因并跳过该技能,其他技能继续加载;showcaseMedia 不是数组时同样跳过
- 加载器增加接受 fs.FS 的内部入口,正式入口仍使用 embed FS
- 补充测试:自带最小 fixture 与真实内置技能的封面地址保留、字段格式与冲突、跳过隔离与日志

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:23:51 +08:00
committed by GitHub
parent 2b9ce566e7
commit 04d5a9c4f0
2 changed files with 208 additions and 9 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io/fs"
"log"
"path"
"strconv"
"strings"
@@ -80,7 +81,13 @@ func (s *Service) EnsureBuiltinSkills() error {
func loadBuiltinSkillPackages(tombstones interface {
BuiltinSkillTombstoned(string) (bool, error)
}) ([]builtinSkillPackage, error) {
entries, err := builtinSkillFiles.ReadDir(".")
return loadBuiltinSkillPackagesFromFS(builtinSkillFiles, tombstones)
}
func loadBuiltinSkillPackagesFromFS(filesystem fs.FS, tombstones interface {
BuiltinSkillTombstoned(string) (bool, error)
}) ([]builtinSkillPackage, error) {
entries, err := fs.ReadDir(filesystem, ".")
if err != nil {
return nil, fmt.Errorf("读取内置技能目录失败: %w", err)
}
@@ -100,7 +107,7 @@ func loadBuiltinSkillPackages(tombstones interface {
seen[skillID] = struct{}{}
base := skillID
body, err := builtinSkillFiles.ReadFile(path.Join(base, "SKILL.md"))
body, err := fs.ReadFile(filesystem, path.Join(base, "SKILL.md"))
if err != nil {
return nil, fmt.Errorf("读取内置技能 %s 入口文件失败: %w", skillID, err)
}
@@ -135,7 +142,7 @@ func loadBuiltinSkillPackages(tombstones interface {
}
files := make(map[string][]byte)
if err := fs.WalkDir(builtinSkillFiles, base, func(filePath string, item fs.DirEntry, walkErr error) error {
if err := fs.WalkDir(filesystem, base, func(filePath string, item fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
@@ -147,7 +154,7 @@ func loadBuiltinSkillPackages(tombstones interface {
if err != nil {
return err
}
content, err := builtinSkillFiles.ReadFile(path.Join(base, relative))
content, err := fs.ReadFile(filesystem, path.Join(base, relative))
if err != nil {
return err
}
@@ -161,11 +168,10 @@ func loadBuiltinSkillPackages(tombstones interface {
return nil, fmt.Errorf("内置技能 %s 文件包无效: %w", skillID, err)
}
showcaseMedia := []SkillShowcaseMedia{}
if metadata.ShowcaseMediaRaw != "" {
if err := json.Unmarshal([]byte(metadata.ShowcaseMediaRaw), &showcaseMedia); err != nil {
return nil, fmt.Errorf("内置技能 %s 展示媒体无效: %w", skillID, err)
}
showcaseMedia, err := parseBuiltinShowcaseMedia(metadata.ShowcaseMediaRaw)
if err != nil {
log.Printf("内置技能 %s 展示媒体无效,跳过加载: %v", skillID, err)
continue
}
mediaJSON, err := json.Marshal(showcaseMedia)
if err != nil {
@@ -186,6 +192,53 @@ func loadBuiltinSkillPackages(tombstones interface {
return packages, nil
}
func parseBuiltinShowcaseMedia(raw string) ([]SkillShowcaseMedia, error) {
items := []SkillShowcaseMedia{}
if strings.TrimSpace(raw) == "" {
return items, nil
}
if !strings.HasPrefix(strings.TrimSpace(raw), "[") {
return nil, fmt.Errorf("showcaseMedia 必须是数组")
}
var entries []map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
return nil, err
}
for index, entry := range entries {
for _, field := range []struct{ snake, camel string }{
{"showcase_uri", "showcaseUri"}, {"showcase_url", "showcaseUrl"},
} {
legacy, exists := entry[field.snake]
if !exists {
continue
}
if current, exists := entry[field.camel]; exists {
var a, b string
if err := json.Unmarshal(legacy, &a); err != nil {
return nil, err
}
if err := json.Unmarshal(current, &b); err != nil {
return nil, err
}
if a != b {
return nil, fmt.Errorf("showcaseMedia[%d] 的 %s 与 %s 冲突", index, field.snake, field.camel)
}
}
entry[field.camel] = legacy
}
data, err := json.Marshal(entry)
if err != nil {
return nil, err
}
var item SkillShowcaseMedia
if err := json.Unmarshal(data, &item); err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}
func normalizeBuiltinSkillMetadata(skillID string, metadata builtinSkillMetadata) (builtinSkillMetadata, error) {
if metadata.Owner == "" {
metadata.Owner = "yingce-system"

View File

@@ -0,0 +1,146 @@
package skills
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"log"
"strings"
"testing"
"testing/fstest"
"infinite-canvas/backend/builtin"
)
func TestBuiltinShowcasePreservesSnakeCaseURL(t *testing.T) {
const path = "珠宝电商图文视频一站式/SKILL.md"
t.Run("self-contained", func(t *testing.T) {
files := fstest.MapFS{
path: &fstest.MapFile{Data: []byte(`---
name: 测试
description: 描述
metadata:
showcaseMedia: [{"type":"image","showcase_uri":"fixture/image.png","showcase_url":"https://example.com/image.png"}]
---
正文
`)},
}
assertBuiltinShowcasePreservesSnakeCaseURL(t, files, path)
})
var files fs.FS = builtin.FS
// Only absence of the sample from the catalog is optional. Unreadable files
// and real samples rejected by the loader must still fail.
if _, err := fs.Stat(files, path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
t.Log("real snake_case sample absent from catalog; self-contained fixture verified")
return
}
t.Fatal(err)
}
t.Run("real-builtin", func(t *testing.T) {
assertBuiltinShowcasePreservesSnakeCaseURL(t, files, path)
})
}
func assertBuiltinShowcasePreservesSnakeCaseURL(t *testing.T, files fs.FS, path string) {
t.Helper()
packages, err := loadBuiltinSkillPackagesFromFS(files, nil)
if err != nil {
t.Fatal(err)
}
for _, item := range packages {
if item.skill.MarkdownURL != path {
continue
}
metadata, err := parseBuiltinSkillMetadata(item.archive.Files["SKILL.md"])
if err != nil {
t.Fatal(err)
}
var source []map[string]string
if err := json.Unmarshal([]byte(metadata.ShowcaseMediaRaw), &source); err != nil {
t.Fatal(err)
}
var got []SkillShowcaseMedia
if err := json.Unmarshal([]byte(item.skill.ShowcaseMediaJSON), &got); err != nil {
t.Fatal(err)
}
if len(source) == 0 || source[0]["showcase_url"] == "" || len(got) != len(source) {
t.Fatal("expected nonempty snake_case showcase fixture")
}
if got[0].ShowcaseURL != source[0]["showcase_url"] {
t.Fatalf("builtin showcase URL = %q, want original nonempty snake_case URL", got[0].ShowcaseURL)
}
return
}
t.Fatal("builtin fixture not found")
}
func TestBuiltinShowcaseFormats(t *testing.T) {
for _, tc := range []struct {
name, raw, uri, url, wantError string
count int
}{
{"snake", `[{"type":"image","showcase_uri":"uri","showcase_url":"https://example.com/a.png"}]`, "uri", "https://example.com/a.png", "", 1},
{"camel", `[{"type":"image","showcaseUri":"uri","showcaseUrl":"https://example.com/a.png"}]`, "uri", "https://example.com/a.png", "", 1},
{"equal", `[{"type":"image","showcase_uri":"uri","showcaseUri":"uri","showcase_url":"url","showcaseUrl":"url"}]`, "uri", "url", "", 1},
{"conflicting URL", `[{"showcase_url":"a","showcaseUrl":"b"}]`, "", "", "showcase_url 与 showcaseUrl 冲突", 0},
{"conflicting URI", `[{"showcase_uri":"a","showcaseUri":"b"}]`, "", "", "showcase_uri 与 showcaseUri 冲突", 0},
{"explicit empty conflicts", `[{"showcase_url":"a","showcaseUrl":""}]`, "", "", "冲突", 0},
{"missing fields", `[{"type":"image"}]`, "", "", "", 1},
{"missing metadata", "", "", "", "", 0},
{"empty array", `[]`, "", "", "", 0},
{"object", `{}`, "", "", "必须是数组", 0},
{"string", `"url"`, "", "", "必须是数组", 0},
{"null", `null`, "", "", "必须是数组", 0},
{"invalid field", `[{"showcase_url":123}]`, "", "", "cannot unmarshal", 0},
} {
t.Run(tc.name, func(t *testing.T) {
items, err := parseBuiltinShowcaseMedia(tc.raw)
if tc.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %v, want %s", err, tc.wantError)
}
return
}
if err != nil || len(items) != tc.count {
t.Fatalf("items = %#v, error = %v", items, err)
}
if len(items) > 0 && (items[0].ShowcaseURI != tc.uri || items[0].ShowcaseURL != tc.url) {
t.Fatalf("items = %#v", items)
}
encoded, err := json.Marshal(items)
if err != nil || strings.Contains(string(encoded), "showcase_") {
t.Fatalf("output = %s, error = %v", encoded, err)
}
if len(items) > 0 && (!strings.Contains(string(encoded), `"showcaseUri"`) || !strings.Contains(string(encoded), `"showcaseUrl"`)) {
t.Fatalf("missing camelCase keys: %s", encoded)
}
})
}
}
func TestBuiltinShowcaseSkipsInvalidSkillAndLogsReason(t *testing.T) {
fixture := func(raw string) *fstest.MapFile {
return &fstest.MapFile{Data: []byte("---\nname: 测试\ndescription: 描述\nmetadata:\n showcaseMedia: " + raw + "\n---\n正文\n")}
}
files := fstest.MapFS{
"a-conflict/SKILL.md": fixture(`[{"showcase_url":"a","showcaseUrl":"b"}]`),
"b-invalid/SKILL.md": fixture(`{}`),
"c-valid/SKILL.md": fixture(`[{"type":"image","showcase_url":"https://example.com/a.png"}]`),
}
var output bytes.Buffer
previous := log.Writer()
log.SetOutput(&output)
t.Cleanup(func() { log.SetOutput(previous) })
packages, err := loadBuiltinSkillPackagesFromFS(files, nil)
if err != nil || len(packages) != 1 || packages[0].skill.ID != "c-valid" {
t.Fatalf("packages = %#v, error = %v", packages, err)
}
for _, want := range []string{"a-conflict", "showcase_url 与 showcaseUrl 冲突", "b-invalid", "必须是数组", "跳过加载"} {
if !strings.Contains(output.String(), want) {
t.Errorf("log missing %q: %s", want, output.String())
}
}
}