Files
ttstd-infinite-canvas/backend/internal/skills/skill_packages.go

1250 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package skills
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/outbound"
"gorm.io/gorm"
)
const (
maxSkillPackageBytes = 20 << 20
maxSkillFileBytes = 8 << 20
maxSkillPackageFiles = 512
maxSkillPreviewBytes = 512 << 10
SkillPackageUploadMaxBytes = maxSkillPackageBytes + (1 << 20)
)
type SkillInstallRequest struct {
Name string
Description string
Tag string
IsPrivate bool
}
type SkillGitHubInstallRequest struct {
URL string `json:"url"`
Ref string `json:"ref"`
Subdir string `json:"subdir"`
Tag string `json:"tag"`
IsPrivate bool `json:"isPrivate"`
AutoUpdate bool `json:"autoUpdate"`
}
type SkillPackageFileItem struct {
Path string `json:"path"`
Kind string `json:"kind"`
MimeType string `json:"mimeType"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type SkillPackageFileContent struct {
File SkillPackageFileItem `json:"file"`
Content string `json:"content"`
Binary bool `json:"binary"`
}
type SkillPackageBundleFile struct {
Path string `json:"path"`
MimeType string `json:"mimeType"`
ContentBase64 string `json:"contentBase64"`
}
type SkillPackageBundle struct {
SkillID string `json:"skillId"`
Name string `json:"name"`
Description string `json:"description"`
VersionID string `json:"versionId"`
Version string `json:"version"`
ContentHash string `json:"contentHash"`
Files []SkillPackageBundleFile `json:"files"`
}
type SkillFileSearchResult struct {
Path string `json:"path"`
Line int `json:"line"`
Snippet string `json:"snippet"`
}
type skillPackageMetadata struct {
Name string
Description string
Version string
}
type skillPackageArchive struct {
Files map[string][]byte
Metadata skillPackageMetadata
ContentHash string
TotalBytes int64
}
type skillPackageSnapshot struct {
skill *model.Skill
version *model.SkillVersion
files []model.SkillFile
legacy bool
contents map[string][]byte
}
type githubSkillSpec struct {
Owner string
Repo string
Ref string
Subdir string
}
func (s *Service) EnsureSkillPackages() error {
skills, err := s.repo.SkillsForPackageEnsure()
if err != nil {
return err
}
for index := range skills {
skill := &skills[index]
version, files, healthErr := s.skillPackageHealth(skill)
if healthErr == nil {
if err := s.syncSkillPackageMetadata(skill, version, files); err != nil {
return fmt.Errorf("同步技能 %s 文件包元数据失败: %w", skill.ID, err)
}
// User-installed ZIP/GitHub skills are authoritative in skill_files and
// must never be replaced by the legacy instruction column at startup.
if skill.Source == skillSourceUser {
continue
}
if strings.TrimSpace(skill.Instruction) == "" {
continue
}
archive, archiveErr := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500))
if archiveErr != nil {
log.Printf("技能 %s 的内置正文无法刷新文件包:%v", skill.ID, archiveErr)
continue
}
if version.ContentHash == archive.ContentHash {
continue
}
if err := s.addSkillArchiveVersion(skill, archive, skillSourceTypeForRepair(skill), skill.SourceURL, skill.SourceRef, skill.SourceSubdir, skill.SourceCommit, skill.AutoUpdate); err != nil {
return fmt.Errorf("刷新技能 %s 文件包失败: %w", skill.ID, err)
}
continue
}
// A legacy row may contain only skills.instruction. Rebuild that single
// entry when package metadata, files, or the archive is missing. If no
// source body exists, keep the service available and leave a diagnostic
// instead of failing every user request during boot.
if strings.TrimSpace(skill.Instruction) == "" {
log.Printf("技能 %s 文件包不完整且没有 legacy instruction,跳过自动修复:%v", skill.ID, healthErr)
continue
}
archive, archiveErr := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500))
if archiveErr != nil {
log.Printf("技能 %s 文件包损坏且 legacy instruction 无法重建:%v", skill.ID, archiveErr)
continue
}
if err := s.addSkillArchiveVersion(skill, archive, skillSourceTypeForRepair(skill), skill.SourceURL, skill.SourceRef, skill.SourceSubdir, skill.SourceCommit, skill.AutoUpdate); err != nil {
return fmt.Errorf("修复技能 %s 文件包失败: %w", skill.ID, err)
}
}
return nil
}
func (s *Service) InstallSkillUpload(userID string, sourceType string, header *multipart.FileHeader, req SkillInstallRequest) (*SkillItem, error) {
sourceType = strings.ToLower(strings.TrimSpace(sourceType))
if sourceType == "" && header != nil {
switch strings.ToLower(path.Ext(header.Filename)) {
case ".md", ".markdown":
sourceType = "markdown"
case ".zip":
sourceType = "zip"
}
}
if sourceType != "markdown" && sourceType != "zip" {
return nil, kernel.BadAuthRequest("技能文件仅支持 Markdown 或 ZIP")
}
if header == nil || header.Size <= 0 || header.Size > maxSkillPackageBytes {
return nil, kernel.BadAuthRequest("技能文件大小必须在 1B-20MB 之间")
}
file, err := header.Open()
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxSkillPackageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxSkillPackageBytes {
return nil, kernel.BadAuthRequest("技能文件不能超过 20MB")
}
var archive skillPackageArchive
if sourceType == "markdown" {
archive, err = archiveFromMarkdown(data, req.Name, req.Description)
} else {
archive, err = archiveFromZip(data, "")
}
if err != nil {
return nil, err
}
return s.createSkillFromArchive(userID, archive, req, sourceType, "", "", "", "", false)
}
func (s *Service) InstallGitHubSkill(userID string, req SkillGitHubInstallRequest) (*SkillItem, error) {
spec, err := parseGitHubSkillURL(req.URL, req.Ref, req.Subdir)
if err != nil {
return nil, err
}
archive, commit, canonicalURL, resolvedRef, err := fetchGitHubSkillArchive(context.Background(), spec)
if err != nil {
var appErr *kernel.AppError
if errors.As(err, &appErr) {
return nil, err
}
return nil, kernel.WrapAppError(http.StatusBadGateway, "GitHub 技能读取失败,请检查仓库地址和网络", err)
}
return s.createSkillFromArchive(userID, archive, SkillInstallRequest{Tag: req.Tag, IsPrivate: req.IsPrivate}, "github", canonicalURL, resolvedRef, spec.Subdir, commit, req.AutoUpdate)
}
func (s *Service) SyncGitHubSkill(userID string, skillID string) (*SkillItem, error) {
skill, err := s.ownedSkill(userID, skillID)
if err != nil {
return nil, err
}
if skill.SourceType != "github" {
return nil, kernel.BadAuthRequest("只有 GitHub 技能可以同步")
}
if err := s.syncGitHubSkill(skill); err != nil {
var appErr *kernel.AppError
if errors.As(err, &appErr) {
return nil, err
}
return nil, kernel.WrapAppError(http.StatusBadGateway, "GitHub 技能同步失败,请稍后重试", err)
}
return s.SkillDetail(userID, skill.ID)
}
func (s *Service) StartSyncWorker(ctx context.Context) {
s.runWorkerLoop(func(ctx context.Context) {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
skills, err := s.repo.AutoUpdatingGitHubSkills()
if err != nil {
continue
}
for index := range skills {
_ = s.syncGitHubSkillWithContext(ctx, &skills[index])
}
}
}
})
}
func (s *Service) syncGitHubSkill(skill *model.Skill) error {
return s.syncGitHubSkillWithContext(context.Background(), skill)
}
func (s *Service) syncGitHubSkillWithContext(ctx context.Context, skill *model.Skill) error {
now := time.Now()
spec, err := parseGitHubSkillURL(skill.SourceURL, skill.SourceRef, skill.SourceSubdir)
if err != nil {
skill.SyncStatus = "failed"
skill.SyncError = err.Error()
skill.LastCheckedAt = &now
_ = s.repo.SaveSkill(skill)
return err
}
archive, commit, canonicalURL, resolvedRef, err := fetchGitHubSkillArchive(ctx, spec)
skill.LastCheckedAt = &now
if err != nil {
skill.SyncStatus = "failed"
skill.SyncError = err.Error()
_ = s.repo.SaveSkill(skill)
return err
}
if commit == skill.SourceCommit {
skill.SyncStatus = "synced"
skill.SyncError = ""
return s.repo.SaveSkill(skill)
}
skill.Name = archive.Metadata.Name
skill.Description = archive.Metadata.Description
skill.Instruction = string(archive.Files["SKILL.md"])
if err := s.addSkillArchiveVersion(skill, archive, "github", canonicalURL, resolvedRef, spec.Subdir, commit, skill.AutoUpdate); err != nil {
skill.SyncStatus = "failed"
skill.SyncError = err.Error()
_ = s.repo.SaveSkill(skill)
return err
}
return nil
}
func (s *Service) createSingleMarkdownSkill(userID string, req SkillMutationRequest) (*SkillItem, error) {
archive, err := archiveFromMarkdown([]byte(req.Instruction), req.SkillName, req.Description)
if err != nil {
return nil, err
}
return s.createSkillFromArchive(userID, archive, SkillInstallRequest{Name: req.SkillName, Description: req.Description, Tag: req.Tag, IsPrivate: req.IsPrivate}, "markdown", req.MarkdownURL, "", "", "", false)
}
func (s *Service) updateSingleMarkdownSkill(skill *model.Skill, req SkillMutationRequest) error {
if strings.TrimSpace(req.Instruction) == "" {
return s.repo.SaveSkill(skill)
}
archive, err := archiveFromMarkdown([]byte(req.Instruction), req.SkillName, req.Description)
if err != nil {
return err
}
skill.Instruction = req.Instruction
return s.addSkillArchiveVersion(skill, archive, "markdown", req.MarkdownURL, "", "", "", false)
}
func (s *Service) createSkillFromArchive(userID string, archive skillPackageArchive, req SkillInstallRequest, sourceType string, sourceURL string, sourceRef string, sourceSubdir string, sourceCommit string, autoUpdate bool) (*SkillItem, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
name = archive.Metadata.Name
}
description := strings.TrimSpace(req.Description)
if description == "" {
description = archive.Metadata.Description
}
if utf8.RuneCountInString(name) == 0 || utf8.RuneCountInString(name) > 80 {
return nil, kernel.BadAuthRequest("技能名称必须为 1-80 个字符")
}
if utf8.RuneCountInString(description) == 0 || utf8.RuneCountInString(description) > 500 {
return nil, kernel.BadAuthRequest("技能简介必须为 1-500 个字符")
}
tag := strings.TrimSpace(req.Tag)
if _, ok := skillCategoryLabels[tag]; !ok {
tag = "others"
}
skillID := kernel.NewID()
versionID := kernel.NewID()
packageKey, version, files, err := s.persistSkillArchive(skillID, versionID, archive, sourceCommit)
if err != nil {
return nil, err
}
now := time.Now()
versionLabel := archive.Metadata.Version
if versionLabel == "" {
versionLabel = "1"
if sourceCommit != "" {
versionLabel = shortCommit(sourceCommit)
}
}
version.VersionLabel = versionLabel
skill := &model.Skill{
ID: skillID, OwnerID: userID, Name: name, Description: description, Instruction: string(archive.Files["SKILL.md"]),
CurrentVersionID: versionID, VersionLabel: versionLabel, ContentHash: archive.ContentHash, FileCount: len(archive.Files), TotalBytes: archive.TotalBytes,
SourceType: sourceType, SourceURL: sourceURL, SourceRef: sourceRef, SourceSubdir: sourceSubdir, SourceCommit: sourceCommit,
SyncStatus: "synced", AutoUpdate: autoUpdate, LastCheckedAt: &now, LastSyncedAt: &now,
Status: skillStatusEnabled, Source: skillSourceUser, Tag: tag, IsPrivate: req.IsPrivate, MarkdownURL: sourceURL, ShowcaseMediaJSON: "[]",
}
state := &model.UserSkillState{ID: kernel.NewID(), UserID: userID, SkillID: skillID, Added: true, InstalledVersionID: versionID, AutoUpdate: autoUpdate}
if err := s.repo.CreateSkillWithPackage(skill, version, files, state); err != nil {
_ = os.Remove(filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey)))
return nil, err
}
return s.SkillDetail(userID, skillID)
}
func (s *Service) addSkillArchiveVersion(skill *model.Skill, archive skillPackageArchive, sourceType string, sourceURL string, sourceRef string, sourceSubdir string, sourceCommit string, autoUpdate bool) error {
versionID := kernel.NewID()
packageKey, version, files, err := s.persistSkillArchive(skill.ID, versionID, archive, sourceCommit)
if err != nil {
return err
}
versionLabel := archive.Metadata.Version
if versionLabel == "" {
versionLabel = "1"
if sourceCommit != "" {
versionLabel = shortCommit(sourceCommit)
} else if skill.CurrentVersionID != "" {
versionLabel = time.Now().UTC().Format("20060102-150405")
}
}
version.VersionLabel = versionLabel
now := time.Now()
skill.CurrentVersionID = versionID
skill.VersionLabel = versionLabel
skill.ContentHash = archive.ContentHash
skill.FileCount = len(archive.Files)
skill.TotalBytes = archive.TotalBytes
skill.SourceType = sourceType
skill.SourceURL = sourceURL
skill.SourceRef = sourceRef
skill.SourceSubdir = sourceSubdir
skill.SourceCommit = sourceCommit
skill.SyncStatus = "synced"
skill.SyncError = ""
skill.AutoUpdate = autoUpdate
skill.LastCheckedAt = &now
skill.LastSyncedAt = &now
if err := s.repo.AddSkillVersion(skill, version, files); err != nil {
_ = os.Remove(filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey)))
return err
}
return nil
}
func (s *Service) persistSkillArchive(skillID string, versionID string, archive skillPackageArchive, sourceCommit string) (string, *model.SkillVersion, []model.SkillFile, error) {
packageKey := path.Join(skillID, versionID+".zip")
absolute := filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(packageKey))
if err := os.MkdirAll(filepath.Dir(absolute), 0o750); err != nil {
return "", nil, nil, err
}
data, err := encodeSkillArchive(archive.Files)
if err != nil {
return "", nil, nil, err
}
temporary, err := os.CreateTemp(filepath.Dir(absolute), ".skill-*.zip")
if err != nil {
return "", nil, nil, err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return "", nil, nil, err
}
if _, err := temporary.Write(data); err != nil {
temporary.Close()
return "", nil, nil, err
}
if err := temporary.Close(); err != nil {
return "", nil, nil, err
}
if err := os.Rename(temporaryPath, absolute); err != nil {
return "", nil, nil, err
}
version := &model.SkillVersion{ID: versionID, SkillID: skillID, ContentHash: archive.ContentHash, EntryPath: "SKILL.md", PackageKey: packageKey, FileCount: len(archive.Files), TotalBytes: archive.TotalBytes, SourceCommit: sourceCommit}
paths := sortedSkillPaths(archive.Files)
files := make([]model.SkillFile, 0, len(paths))
for _, filePath := range paths {
content := archive.Files[filePath]
digest := sha256.Sum256(content)
files = append(files, model.SkillFile{ID: kernel.NewID(), SkillVersionID: versionID, Path: filePath, Kind: skillFileKind(filePath), MimeType: skillFileMime(filePath, content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])})
}
return packageKey, version, files, nil
}
func (s *Service) SkillPackageFiles(userID string, skillID string) ([]SkillPackageFileItem, error) {
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, err
}
items := make([]SkillPackageFileItem, 0, len(snapshot.files))
for _, file := range snapshot.files {
items = append(items, skillFileItem(file))
}
return items, nil
}
func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) {
_, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath)
if err != nil {
return nil, err
}
binary := !isPreviewText(file.MimeType, file.Path)
if binary {
return &SkillPackageFileContent{File: skillFileItem(*file), Binary: true}, nil
}
if len(content) > maxSkillPreviewBytes {
return nil, kernel.BadAuthRequest("文件超过 512KB,请下载后查看")
}
return &SkillPackageFileContent{File: skillFileItem(*file), Content: string(content)}, nil
}
func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath string) ([]byte, string, string, error) {
_, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath)
if err != nil {
return nil, "", "", err
}
return content, file.MimeType, path.Base(file.Path), nil
}
func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPackageBundle, error) {
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, err
}
bundle := &SkillPackageBundle{SkillID: snapshot.skill.ID, Name: snapshot.skill.Name, Description: snapshot.skill.Description, VersionID: snapshot.version.ID, Version: snapshot.version.VersionLabel, ContentHash: snapshot.version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(snapshot.files))}
for _, file := range snapshot.files {
content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path)
if err != nil {
return nil, err
}
bundle.Files = append(bundle.Files, SkillPackageBundleFile{Path: file.Path, MimeType: file.MimeType, ContentBase64: base64.StdEncoding.EncodeToString(content)})
}
return bundle, nil
}
func (s *Service) SearchSkillPackage(userID string, skillID string, query string) ([]SkillFileSearchResult, error) {
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" || utf8.RuneCountInString(query) > 120 {
return nil, kernel.BadAuthRequest("搜索关键词必须为 1-120 个字符")
}
needle := strings.ToLower(query)
results := make([]SkillFileSearchResult, 0, 20)
for _, file := range snapshot.files {
if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes {
continue
}
content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path)
if err != nil {
return nil, err
}
for lineIndex, line := range strings.Split(string(content), "\n") {
if strings.Contains(strings.ToLower(line), needle) {
results = append(results, SkillFileSearchResult{Path: file.Path, Line: lineIndex + 1, Snippet: kernel.TruncateRunes(strings.TrimSpace(line), 240)})
if len(results) >= 50 {
break
}
}
}
}
return results, nil
}
func (s *Service) readSkillPackageSnapshotFile(userID string, skillID string, filePath string) (*skillPackageSnapshot, *model.SkillFile, []byte, error) {
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, nil, nil, err
}
filePath, err = normalizeSkillPath(filePath)
if err != nil {
return nil, nil, nil, err
}
for index := range snapshot.files {
if snapshot.files[index].Path != filePath {
continue
}
content, err := s.readSkillPackageSnapshotEntry(snapshot, filePath)
return snapshot, &snapshot.files[index], content, err
}
return nil, nil, nil, kernel.BadAuthRequest("技能文件不存在")
}
func (s *Service) loadSkillPackageSnapshot(userID string, skillID string) (*skillPackageSnapshot, error) {
skill, err := s.visibleSkill(userID, skillID)
if err != nil {
return nil, err
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return legacySkillPackageSnapshot(skill)
}
if err != nil {
return nil, err
}
if version.SkillID != skill.ID {
return nil, fmt.Errorf("技能 %s 当前版本归属不一致", skill.ID)
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, err
}
if len(files) == 0 {
return legacySkillPackageSnapshot(skill)
}
if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil {
return nil, err
}
return &skillPackageSnapshot{skill: skill, version: version, files: files}, nil
}
func (s *Service) readSkillPackageSnapshotEntry(snapshot *skillPackageSnapshot, filePath string) ([]byte, error) {
if snapshot.contents != nil {
content, ok := snapshot.contents[filePath]
if !ok {
return nil, kernel.BadAuthRequest("技能文件不存在")
}
return content, nil
}
return s.readSkillArchiveEntry(snapshot.version, filePath)
}
func legacySkillPackageSnapshot(skill *model.Skill) (*skillPackageSnapshot, error) {
if strings.TrimSpace(skill.Instruction) == "" {
return nil, fmt.Errorf("技能 %s 缺少当前版本、文件清单和 legacy instruction", skill.ID)
}
content := []byte(skill.Instruction)
files := map[string][]byte{"SKILL.md": content}
digest := sha256.Sum256(content)
hash, total := contentHashAndSize(files)
versionID := strings.TrimSpace(skill.CurrentVersionID)
versionLabel := strings.TrimSpace(skill.VersionLabel)
if versionLabel == "" {
versionLabel = "legacy"
}
version := &model.SkillVersion{ID: versionID, SkillID: skill.ID, VersionLabel: versionLabel, ContentHash: hash, EntryPath: "SKILL.md", FileCount: 1, TotalBytes: total}
file := model.SkillFile{SkillVersionID: versionID, Path: "SKILL.md", Kind: "markdown", MimeType: skillFileMime("SKILL.md", content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}
return &skillPackageSnapshot{skill: skill, version: version, files: []model.SkillFile{file}, legacy: true, contents: files}, nil
}
func skillSourceTypeForRepair(skill *model.Skill) string {
if value := strings.TrimSpace(skill.SourceType); value != "" {
return value
}
if skill.Source == skillSourceUser {
return "markdown"
}
return "builtin"
}
func (s *Service) skillPackageHealth(skill *model.Skill) (*model.SkillVersion, []model.SkillFile, error) {
if strings.TrimSpace(skill.CurrentVersionID) == "" {
return nil, nil, gorm.ErrRecordNotFound
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, nil, err
}
if version.SkillID != skill.ID {
return nil, nil, fmt.Errorf("当前版本归属技能不一致")
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, nil, err
}
if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil {
return nil, nil, err
}
return version, files, nil
}
func (s *Service) syncSkillPackageMetadata(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error {
changed := skill.CurrentVersionID != version.ID || skill.VersionLabel != version.VersionLabel || skill.ContentHash != version.ContentHash || skill.FileCount != len(files) || skill.TotalBytes != version.TotalBytes
if !changed {
return nil
}
skill.CurrentVersionID = version.ID
skill.VersionLabel = version.VersionLabel
skill.ContentHash = version.ContentHash
skill.FileCount = len(files)
skill.TotalBytes = version.TotalBytes
return s.repo.SaveSkill(skill)
}
func validateSkillPackageSnapshot(s *Service, skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error {
if len(files) == 0 || version.FileCount != len(files) || version.TotalBytes < 0 {
return fmt.Errorf("技能 %s 文件元数据不完整", skill.ID)
}
if skill.ContentHash != "" && skill.ContentHash != version.ContentHash {
return fmt.Errorf("技能 %s 内容摘要与当前版本不一致", skill.ID)
}
if skill.FileCount != 0 && skill.FileCount != len(files) {
return fmt.Errorf("技能 %s 文件数量与当前版本不一致", skill.ID)
}
if skill.TotalBytes != 0 && skill.TotalBytes != version.TotalBytes {
return fmt.Errorf("技能 %s 文件大小与当前版本不一致", skill.ID)
}
if version.PackageKey == "" {
return fmt.Errorf("技能 %s 当前版本缺少 ZIP 包", skill.ID)
}
contents, err := readSkillArchiveEntries(s.dataDir, version.PackageKey)
if err != nil {
return fmt.Errorf("读取技能 %s ZIP 包失败: %w", skill.ID, err)
}
byPath := make(map[string]model.SkillFile, len(files))
var total int64
for _, file := range files {
if strings.TrimSpace(file.Path) == "" {
return fmt.Errorf("技能 %s 文件清单存在空路径", skill.ID)
}
if version.EntryPath != "" && version.EntryPath != "SKILL.md" {
return fmt.Errorf("技能 %s 当前入口不是 SKILL.md", skill.ID)
}
if _, exists := byPath[file.Path]; exists {
return fmt.Errorf("技能 %s 文件清单包含重复路径 %s", skill.ID, file.Path)
}
byPath[file.Path] = file
content, ok := contents[file.Path]
if !ok {
return fmt.Errorf("技能 %s ZIP 缺少数据库文件 %s", skill.ID, file.Path)
}
digest := sha256.Sum256(content)
if file.Size != int64(len(content)) || file.SHA256 != hex.EncodeToString(digest[:]) {
return fmt.Errorf("技能 %s 文件 %s 元数据与 ZIP 不一致", skill.ID, file.Path)
}
total += int64(len(content))
}
if _, ok := byPath["SKILL.md"]; !ok {
return fmt.Errorf("技能 %s 文件清单缺少 SKILL.md", skill.ID)
}
if len(contents) != len(files) || total != version.TotalBytes {
return fmt.Errorf("技能 %s ZIP 文件数量或大小与版本元数据不一致", skill.ID)
}
hash, _ := contentHashAndSize(contents)
if version.ContentHash == "" || hash != version.ContentHash {
return fmt.Errorf("技能 %s ZIP 内容摘要与版本元数据不一致", skill.ID)
}
return nil
}
func readSkillArchiveEntries(dataDir string, packageKey string) (map[string][]byte, error) {
absolute := filepath.Join(dataDir, "skill-packages", filepath.FromSlash(packageKey))
reader, err := zip.OpenReader(absolute)
if err != nil {
return nil, err
}
defer reader.Close()
if len(reader.File) > maxSkillPackageFiles+64 {
return nil, kernel.BadAuthRequest("技能包文件数量异常")
}
contents := make(map[string][]byte, len(reader.File))
var total int64
for _, entry := range reader.File {
if entry.FileInfo().IsDir() {
continue
}
filePath, err := normalizeSkillPath(entry.Name)
if err != nil {
return nil, err
}
if _, exists := contents[filePath]; exists {
return nil, kernel.BadAuthRequest("技能包包含重复文件路径")
}
if entry.UncompressedSize64 > maxSkillFileBytes {
return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB")
}
file, err := entry.Open()
if err != nil {
return nil, err
}
content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1))
closeErr := file.Close()
if readErr != nil {
return nil, readErr
}
if closeErr != nil {
return nil, closeErr
}
if len(content) > maxSkillFileBytes {
return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB")
}
total += int64(len(content))
if total > maxSkillPackageBytes {
return nil, kernel.BadAuthRequest("技能包解压后不能超过 20MB")
}
contents[filePath] = content
}
return contents, nil
}
func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath string) ([]byte, error) {
absolute := filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(version.PackageKey))
reader, err := zip.OpenReader(absolute)
if err != nil {
return nil, err
}
defer reader.Close()
for _, entry := range reader.File {
if entry.Name != filePath {
continue
}
file, err := entry.Open()
if err != nil {
return nil, err
}
defer file.Close()
return io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1))
}
return nil, kernel.BadAuthRequest("技能文件不存在")
}
func archiveFromMarkdown(data []byte, fallbackName string, fallbackDescription string) (skillPackageArchive, error) {
if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) {
return skillPackageArchive{}, kernel.BadAuthRequest("Markdown 文件为空、过大或不是 UTF-8")
}
metadata := parseSkillPackageMetadata(data)
if metadata.Name == "" {
metadata.Name = strings.TrimSpace(fallbackName)
}
if metadata.Description == "" {
metadata.Description = strings.TrimSpace(fallbackDescription)
}
files := map[string][]byte{"SKILL.md": data}
return finalizeSkillArchive(files, metadata)
}
func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return skillPackageArchive{}, kernel.BadAuthRequest("ZIP 文件无法解析")
}
if len(reader.File) > maxSkillPackageFiles+64 {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包文件数量不能超过 512 个")
}
raw := make(map[string][]byte)
var total int64
for _, entry := range reader.File {
if entry.FileInfo().IsDir() {
continue
}
if entry.Mode()&os.ModeSymlink != 0 || !entry.Mode().IsRegular() {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包不能包含软链接或特殊文件")
}
entryPath, err := normalizeSkillPath(entry.Name)
if err != nil {
return skillPackageArchive{}, err
}
if strings.HasPrefix(entryPath, "__MACOSX/") || path.Base(entryPath) == ".DS_Store" {
continue
}
if _, exists := raw[entryPath]; exists {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包包含重复文件路径")
}
if entry.UncompressedSize64 > maxSkillFileBytes {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB")
}
file, err := entry.Open()
if err != nil {
return skillPackageArchive{}, err
}
content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1))
closeErr := file.Close()
if readErr != nil {
return skillPackageArchive{}, readErr
}
if closeErr != nil {
return skillPackageArchive{}, closeErr
}
if len(content) > maxSkillFileBytes {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB")
}
total += int64(len(content))
if total > maxSkillPackageBytes {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包解压后不能超过 20MB")
}
raw[entryPath] = content
}
files, err := normalizeSkillArchiveRoot(raw, subdir)
if err != nil {
return skillPackageArchive{}, err
}
metadata := parseSkillPackageMetadata(files["SKILL.md"])
return finalizeSkillArchive(files, metadata)
}
func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string][]byte, error) {
if len(raw) == 0 {
return nil, kernel.BadAuthRequest("技能包为空")
}
paths := sortedSkillPaths(raw)
firstParts := strings.SplitN(paths[0], "/", 2)
if len(firstParts) == 2 {
wrapper := firstParts[0] + "/"
allWrapped := true
for _, filePath := range paths {
if !strings.HasPrefix(filePath, wrapper) {
allWrapped = false
break
}
}
if allWrapped {
next := make(map[string][]byte, len(raw))
for filePath, content := range raw {
next[strings.TrimPrefix(filePath, wrapper)] = content
}
raw = next
}
}
subdir = strings.Trim(strings.ReplaceAll(strings.TrimSpace(subdir), "\\", "/"), "/")
if subdir != "" {
normalized, err := normalizeSkillPath(subdir)
if err != nil {
return nil, err
}
prefix := normalized + "/"
filtered := make(map[string][]byte)
for filePath, content := range raw {
if strings.HasPrefix(filePath, prefix) {
filtered[strings.TrimPrefix(filePath, prefix)] = content
}
}
if len(filtered) == 0 {
return nil, kernel.BadAuthRequest("GitHub 子目录不存在或为空")
}
raw = filtered
}
skillRoots := make([]string, 0, 2)
for filePath := range raw {
if filePath == "SKILL.md" {
skillRoots = []string{""}
break
}
if path.Base(filePath) == "SKILL.md" {
skillRoots = append(skillRoots, path.Dir(filePath))
}
}
if len(skillRoots) == 0 {
return nil, kernel.BadAuthRequest("技能包中缺少 SKILL.md")
}
if len(skillRoots) > 1 {
return nil, kernel.BadAuthRequest("技能包包含多个 SKILL.md,请指定单个技能目录")
}
root := skillRoots[0]
files := make(map[string][]byte)
for filePath, content := range raw {
if root != "" {
prefix := root + "/"
if !strings.HasPrefix(filePath, prefix) {
continue
}
filePath = strings.TrimPrefix(filePath, prefix)
}
files[filePath] = content
}
if len(files) > maxSkillPackageFiles {
return nil, kernel.BadAuthRequest("技能包文件数量不能超过 512 个")
}
return files, nil
}
func contentHashAndSize(files map[string][]byte) (string, int64) {
hash := sha256.New()
var total int64
for _, filePath := range sortedSkillPaths(files) {
content := files[filePath]
total += int64(len(content))
hash.Write([]byte(filePath))
hash.Write([]byte{0})
hash.Write(content)
hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil)), total
}
func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) {
if _, ok := files["SKILL.md"]; !ok {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包入口必须是 SKILL.md")
}
if metadata.Name == "" {
return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 name,且无法从标题推断")
}
if metadata.Description == "" {
return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 description,且无法从正文推断")
}
if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 {
return skillPackageArchive{}, kernel.BadAuthRequest("技能名称或简介超过长度限制")
}
hash, total := contentHashAndSize(files)
return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hash, TotalBytes: total}, nil
}
func encodeSkillArchive(files map[string][]byte) ([]byte, error) {
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for _, filePath := range sortedSkillPaths(files) {
header := &zip.FileHeader{Name: filePath, Method: zip.Deflate}
header.SetMode(0o600)
header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC))
entry, err := writer.CreateHeader(header)
if err != nil {
return nil, err
}
if _, err := entry.Write(files[filePath]); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func parseSkillPackageMetadata(data []byte) skillPackageMetadata {
text := string(data)
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
metadata := skillPackageMetadata{}
bodyStart := 0
if len(lines) > 2 && strings.TrimSpace(lines[0]) == "---" {
metadataIndent := -1
for index := 1; index < len(lines); index++ {
line := lines[index]
if strings.TrimSpace(line) == "---" {
bodyStart = index + 1
break
}
trimmed := strings.TrimSpace(line)
indent := len(line) - len(strings.TrimLeft(line, " \t"))
if indent == 0 {
metadataIndent = -1
}
key, value, ok := strings.Cut(trimmed, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = yamlScalar(value)
switch {
case indent == 0 && key == "name":
metadata.Name = value
case indent == 0 && key == "description":
metadata.Description = value
case indent == 0 && key == "metadata":
metadataIndent = indent
case metadataIndent >= 0 && indent > metadataIndent && key == "version":
metadata.Version = value
}
}
}
body := lines[bodyStart:]
if metadata.Name == "" {
for _, line := range body {
if strings.HasPrefix(strings.TrimSpace(line), "# ") {
metadata.Name = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "# "))
break
}
}
}
if metadata.Description == "" {
paragraph := make([]string, 0, 4)
for _, line := range body {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
if len(paragraph) > 0 {
break
}
continue
}
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "```") {
continue
}
paragraph = append(paragraph, trimmed)
}
metadata.Description = strings.Join(paragraph, " ")
}
metadata.Name = truncateSkillMetadata(metadata.Name, 80)
metadata.Description = truncateSkillMetadata(metadata.Description, 500)
metadata.Version = truncateSkillMetadata(metadata.Version, 64)
return metadata
}
// truncateSkillMetadata keeps the result within the validation limit. The
// general-purpose TruncateRunes helper appends "...", which is useful for
// display snippets but would make a value exactly at the limit invalid.
func truncateSkillMetadata(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
func yamlScalar(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 && value[0] == '"' {
if decoded, err := strconv.Unquote(value); err == nil {
return strings.TrimSpace(decoded)
}
}
if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' {
return strings.TrimSpace(value[1 : len(value)-1])
}
return value
}
func parseGitHubSkillURL(rawURL string, requestedRef string, requestedSubdir string) (githubSkillSpec, error) {
target, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || target.Scheme != "https" || !strings.EqualFold(target.Hostname(), "github.com") || target.User != nil {
return githubSkillSpec{}, kernel.BadAuthRequest("请输入公开的 https://github.com 仓库地址")
}
parts := strings.Split(strings.Trim(target.Path, "/"), "/")
if len(parts) < 2 {
return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须包含 owner/repository")
}
spec := githubSkillSpec{Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git"), Ref: strings.TrimSpace(requestedRef), Subdir: strings.Trim(strings.ReplaceAll(strings.TrimSpace(requestedSubdir), "\\", "/"), "/")}
if len(parts) >= 4 && parts[2] == "tree" {
if spec.Ref == "" {
spec.Ref = parts[3]
}
if spec.Subdir == "" && len(parts) > 4 {
spec.Subdir = strings.Join(parts[4:], "/")
}
}
if len(parts) > 2 && parts[2] != "tree" {
return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须指向仓库根目录或 tree 子目录")
}
if spec.Owner == "" || spec.Repo == "" {
return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 仓库地址无效")
}
return spec, nil
}
func fetchGitHubSkillArchive(ctx context.Context, spec githubSkillSpec) (skillPackageArchive, string, string, string, error) {
client := &http.Client{Timeout: 30 * time.Second}
resolvedRef := spec.Ref
if resolvedRef == "" {
var repoInfo struct {
DefaultBranch string `json:"default_branch"`
}
if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo)), &repoInfo); err != nil {
return skillPackageArchive{}, "", "", "", err
}
resolvedRef = repoInfo.DefaultBranch
}
var commitInfo struct {
SHA string `json:"sha"`
}
if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(resolvedRef)), &commitInfo); err != nil {
return skillPackageArchive{}, "", "", "", err
}
if commitInfo.SHA == "" {
return skillPackageArchive{}, "", "", "", errors.New("GitHub 未返回提交版本")
}
downloadURL := fmt.Sprintf("https://codeload.github.com/%s/%s/zip/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(commitInfo.SHA))
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent)
response, err := client.Do(request)
if err != nil {
return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, maxSkillPackageBytes+1))
if err != nil {
return skillPackageArchive{}, "", "", "", err
}
if len(data) > maxSkillPackageBytes {
return skillPackageArchive{}, "", "", "", kernel.BadAuthRequest("GitHub 技能包超过 20MB")
}
archive, err := archiveFromZip(data, spec.Subdir)
if err != nil {
return skillPackageArchive{}, "", "", "", err
}
canonicalURL := fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo)
return archive, commitInfo.SHA, canonicalURL, resolvedRef, nil
}
func githubJSON(ctx context.Context, client *http.Client, target string, output any) error {
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent)
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("读取 GitHub 信息失败: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("读取 GitHub 信息失败: HTTP %d", response.StatusCode)
}
return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(output)
}
func normalizeSkillPath(value string) (string, error) {
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if value == "" || strings.ContainsRune(value, 0) || strings.HasPrefix(value, "/") {
return "", kernel.BadAuthRequest("技能文件路径无效")
}
clean := path.Clean(value)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") || strings.HasPrefix(clean, ".git/") || clean == ".git" {
return "", kernel.BadAuthRequest("技能文件路径越界或包含禁止目录")
}
if utf8.RuneCountInString(clean) > 1000 {
return "", kernel.BadAuthRequest("技能文件路径过长")
}
return clean, nil
}
func skillFileMime(filePath string, content []byte) string {
if filePath == "SKILL.md" || strings.HasSuffix(strings.ToLower(filePath), ".md") {
return "text/markdown; charset=utf-8"
}
if detected := mime.TypeByExtension(strings.ToLower(path.Ext(filePath))); detected != "" {
return detected
}
return http.DetectContentType(content)
}
func skillFileKind(filePath string) string {
extension := strings.ToLower(path.Ext(filePath))
switch extension {
case ".md", ".mdx":
return "markdown"
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
return "image"
case ".mp4", ".webm", ".mov":
return "video"
case ".mp3", ".wav", ".m4a", ".ogg":
return "audio"
case ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".sh", ".json", ".yaml", ".yml", ".toml", ".html", ".css":
return "code"
case ".txt", ".csv":
return "text"
default:
return "binary"
}
}
func isPreviewText(mimeType string, filePath string) bool {
return strings.HasPrefix(mimeType, "text/") || strings.Contains(mimeType, "json") || strings.Contains(mimeType, "yaml") || strings.Contains(mimeType, "toml") || skillFileKind(filePath) == "code"
}
func skillFileItem(file model.SkillFile) SkillPackageFileItem {
return SkillPackageFileItem{Path: file.Path, Kind: file.Kind, MimeType: file.MimeType, Size: file.Size, SHA256: file.SHA256}
}
func sortedSkillPaths(files map[string][]byte) []string {
paths := make([]string, 0, len(files))
for filePath := range files {
paths = append(paths, filePath)
}
sort.Strings(paths)
return paths
}
func shortCommit(value string) string {
if len(value) <= 12 {
return value
}
return value[:12]
}
func isSkillPackageNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, os.ErrNotExist)
}