Files
ttstd-infinite-canvas/backend/internal/skills/skills_builtin.go
Catlaxy AI 6f4267e900 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>
2026-10-06 09:23:51 +08:00

376 lines
11 KiB
Go

package skills
import (
"encoding/json"
"fmt"
"io/fs"
"log"
"path"
"strconv"
"strings"
"time"
"unicode/utf8"
builtinAssets "infinite-canvas/backend/builtin"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
)
var builtinSkillFiles = builtinAssets.FS
type builtinSkillMetadata struct {
Name string
Description string
SkillID string
Version string
Author string
Owner string
AuthorAvatarURL string
Tag string
SortWeight int
Source int
CreatedAt time.Time
UpdatedAt time.Time
InitialLike int64
InitialAdded int64
ExtraInfo string
ShowcaseMediaRaw string
}
type builtinSkillPackage struct {
skill model.Skill
archive skillPackageArchive
}
// EnsureBuiltinSkills syncs embedded Markdown packages to the catalog and version tables.
// User relationships remain in their own table and are not changed by catalog updates.
func (s *Service) EnsureBuiltinSkills() error {
packages, err := loadBuiltinSkillPackages(s.repo)
if err != nil {
return err
}
if len(packages) == 0 {
return fmt.Errorf("内置技能目录为空")
}
if s.repo == nil {
return nil
}
rows := make([]model.Skill, 0, len(packages))
for _, item := range packages {
rows = append(rows, item.skill)
}
if err := s.repo.UpsertBuiltinSkills(rows); err != nil {
return fmt.Errorf("同步内置技能目录失败: %w", err)
}
for _, item := range packages {
current, err := s.repo.Skill(item.skill.ID)
if err != nil {
return fmt.Errorf("读取内置技能 %s 同步结果失败: %w", item.skill.ID, err)
}
if current.ContentHash == item.archive.ContentHash && current.CurrentVersionID != "" {
continue
}
if err := s.addSkillArchiveVersion(current, item.archive, "builtin", "", "", "", "", false); err != nil {
return fmt.Errorf("同步内置技能 %s 文件包失败: %w", item.skill.ID, err)
}
}
return nil
}
func loadBuiltinSkillPackages(tombstones interface {
BuiltinSkillTombstoned(string) (bool, error)
}) ([]builtinSkillPackage, error) {
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)
}
packages := make([]builtinSkillPackage, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
skillID := strings.TrimSpace(entry.Name())
if skillID == "" || utf8.RuneCountInString(skillID) > 80 || strings.ContainsAny(skillID, `/\\`) {
return nil, fmt.Errorf("内置技能目录名非法: %q", entry.Name())
}
if _, exists := seen[skillID]; exists {
return nil, fmt.Errorf("内置技能目录重复: %s", skillID)
}
seen[skillID] = struct{}{}
base := skillID
body, err := fs.ReadFile(filesystem, path.Join(base, "SKILL.md"))
if err != nil {
return nil, fmt.Errorf("读取内置技能 %s 入口文件失败: %w", skillID, err)
}
metadata, err := parseBuiltinSkillMetadata(body)
if err != nil {
return nil, fmt.Errorf("内置技能 %s 元数据无效: %w", skillID, err)
}
if metadata.SkillID != "" && metadata.SkillID != skillID {
// The directory is a readable slug; skillId is the stable database key.
stableID := metadata.SkillID
if len(stableID) > 36 {
return nil, fmt.Errorf("内置技能 %s 的 skillId 过长", skillID)
}
if _, exists := seen[stableID]; exists {
return nil, fmt.Errorf("内置技能稳定 ID 重复: %s", stableID)
}
seen[stableID] = struct{}{}
skillID = stableID
}
if tombstones != nil {
deleted, err := tombstones.BuiltinSkillTombstoned(skillID)
if err != nil {
return nil, fmt.Errorf("检查内置技能 %s 删除记录失败: %w", skillID, err)
}
if deleted {
continue
}
}
metadata, err = normalizeBuiltinSkillMetadata(skillID, metadata)
if err != nil {
return nil, err
}
files := make(map[string][]byte)
if err := fs.WalkDir(filesystem, base, func(filePath string, item fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if item.IsDir() {
return nil
}
relative := strings.TrimPrefix(filePath, base+"/")
filePath, err := normalizeSkillPath(relative)
if err != nil {
return err
}
content, err := fs.ReadFile(filesystem, path.Join(base, relative))
if err != nil {
return err
}
files[filePath] = content
return nil
}); err != nil {
return nil, fmt.Errorf("读取内置技能 %s 文件失败: %w", skillID, err)
}
archive, err := finalizeSkillArchive(files, skillPackageMetadata{Name: metadata.Name, Description: metadata.Description, Version: metadata.Version})
if 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 {
return nil, err
}
packages = append(packages, builtinSkillPackage{
skill: model.Skill{
ID: skillID, OwnerID: metadata.Owner, AuthorName: metadata.Author, AuthorAvatarURL: metadata.AuthorAvatarURL,
Name: metadata.Name, Description: metadata.Description, Instruction: string(body),
SourceType: "builtin", Status: skillStatusEnabled, Source: metadata.Source, Tag: metadata.Tag, SortWeight: metadata.SortWeight,
IsPrivate: false, MarkdownURL: base + "/SKILL.md", ShowcaseMediaJSON: string(mediaJSON), ExtraInfo: metadata.ExtraInfo,
InitialLikeCount: metadata.InitialLike, InitialAddedCount: metadata.InitialAdded,
CreatedAt: metadata.CreatedAt, UpdatedAt: metadata.UpdatedAt,
},
archive: archive,
})
}
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"
}
if metadata.Author == "" {
metadata.Author = "影策"
}
if metadata.Tag == "" {
metadata.Tag = "others"
}
if metadata.Source == 0 {
metadata.Source = 3
}
if metadata.Version == "" {
metadata.Version = "1.0.0"
}
if metadata.CreatedAt.IsZero() {
metadata.CreatedAt = time.Now().UTC()
}
if metadata.UpdatedAt.IsZero() {
metadata.UpdatedAt = metadata.CreatedAt
}
if metadata.Name == "" || metadata.Description == "" {
return metadata, fmt.Errorf("内置技能 %s 缺少 name 或 description", skillID)
}
if _, ok := skillCategoryLabels[metadata.Tag]; !ok {
return metadata, fmt.Errorf("内置技能 %s 分类无效: %s", skillID, metadata.Tag)
}
if metadata.AuthorAvatarURL != "" && !validSkillURL(metadata.AuthorAvatarURL) {
return metadata, fmt.Errorf("内置技能 %s 作者头像地址无效", skillID)
}
return metadata, nil
}
func parseBuiltinSkillMetadata(body []byte) (builtinSkillMetadata, error) {
lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
if len(lines) < 3 || strings.TrimSpace(lines[0]) != "---" {
return builtinSkillMetadata{}, fmt.Errorf("缺少 frontmatter")
}
metadata := builtinSkillMetadata{}
inMetadata := false
closed := false
for index := 1; index < len(lines); index++ {
line := lines[index]
if strings.TrimSpace(line) == "---" {
closed = true
break
}
trimmed := strings.TrimSpace(line)
key, value, ok := strings.Cut(trimmed, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = yamlScalar(value)
indent := len(line) - len(strings.TrimLeft(line, " \t"))
if indent == 0 {
inMetadata = key == "metadata"
switch key {
case "name":
metadata.Name = value
case "description":
metadata.Description = value
case "skillId":
metadata.SkillID = value
}
continue
}
if !inMetadata {
continue
}
switch key {
case "skillId":
metadata.SkillID = value
case "version":
metadata.Version = value
case "author":
metadata.Author = value
case "owner":
metadata.Owner = value
case "authorAvatarUrl":
metadata.AuthorAvatarURL = value
case "tag":
metadata.Tag = value
case "sortWeight":
metadata.SortWeight, _ = strconv.Atoi(value)
case "source":
metadata.Source, _ = strconv.Atoi(value)
case "createdAt":
metadata.CreatedAt = parseBuiltinTime(value)
case "updatedAt":
metadata.UpdatedAt = parseBuiltinTime(value)
case "initialLikeCount":
metadata.InitialLike, _ = strconv.ParseInt(value, 10, 64)
case "initialAddedCount":
metadata.InitialAdded, _ = strconv.ParseInt(value, 10, 64)
case "extraInfo":
metadata.ExtraInfo = value
case "showcaseMedia":
metadata.ShowcaseMediaRaw = value
}
}
if !closed {
return builtinSkillMetadata{}, fmt.Errorf("frontmatter 未闭合")
}
return metadata, nil
}
func parseBuiltinTime(value string) time.Time {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}
}
if milliseconds, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.UnixMilli(milliseconds)
}
parsed, _ := time.Parse(time.RFC3339, value)
return parsed
}
func builtinSeedSkillIDs() ([]string, error) {
packages, err := loadBuiltinSkillPackages(nil)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(packages))
for _, item := range packages {
ids = append(ids, item.skill.ID)
}
if len(ids) == 0 {
return nil, kernel.BadAuthRequest("内置技能不能为空")
}
return ids, nil
}