Files
ttstd-infinite-canvas/backend/internal/service/skill_packages.go
T

1010 lines
33 KiB
Go

package service
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"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:"is_private"`
AutoUpdate bool `json:"auto_update"`
}
type SkillPackageFileItem struct {
Path string `json:"path"`
Kind string `json:"kind"`
MimeType string `json:"mime_type"`
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:"mime_type"`
ContentBase64 string `json:"content_base64"`
}
type SkillPackageBundle struct {
SkillID string `json:"skill_id"`
Name string `json:"name"`
Description string `json:"description"`
VersionID string `json:"version_id"`
Version string `json:"version"`
ContentHash string `json:"content_hash"`
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 githubSkillSpec struct {
Owner string
Repo string
Ref string
Subdir string
}
func (s *Service) EnsureSkillPackages() error {
skills, err := s.repo.SkillsForPackageEnsure(skillSourceUser)
if err != nil {
return err
}
for index := range skills {
skill := &skills[index]
archive, err := archiveFromMarkdown([]byte(skill.Instruction), skill.Name, skill.Description)
if err != nil {
return fmt.Errorf("迁移技能 %s 文件包失败: %w", skill.ID, err)
}
if skill.CurrentVersionID != "" && skill.ContentHash == archive.ContentHash {
continue
}
sourceType := "builtin"
if skill.Source == skillSourceUser {
sourceType = "markdown"
}
if err := s.addSkillArchiveVersion(skill, archive, sourceType, "", "", "", "", false); 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, BadAuthRequest("技能文件仅支持 Markdown 或 ZIP")
}
if header == nil || header.Size <= 0 || header.Size > maxSkillPackageBytes {
return nil, 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, 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 *AppError
if errors.As(err, &appErr) {
return nil, err
}
return nil, 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, BadAuthRequest("只有 GitHub 技能可以同步")
}
if err := s.syncGitHubSkill(skill); err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
return nil, err
}
return nil, WrapAppError(http.StatusBadGateway, "GitHub 技能同步失败,请稍后重试", err)
}
return s.SkillDetail(userID, skill.ID)
}
func (s *Service) startSkillSyncWorker(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, BadAuthRequest("技能名称必须为 1-80 个字符")
}
if utf8.RuneCountInString(description) == 0 || utf8.RuneCountInString(description) > 500 {
return nil, BadAuthRequest("技能简介必须为 1-500 个字符")
}
tag := strings.TrimSpace(req.Tag)
if _, ok := skillCategoryLabels[tag]; !ok {
tag = "others"
}
skillID := newID()
versionID := 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: 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 := 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: 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) {
skill, err := s.visibleSkill(userID, skillID)
if err != nil {
return nil, err
}
files, err := s.repo.SkillFiles(skill.CurrentVersionID)
if err != nil {
return nil, err
}
items := make([]SkillPackageFileItem, 0, len(files))
for _, file := range files {
items = append(items, skillFileItem(file))
}
return items, nil
}
func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) {
skill, version, file, content, err := s.readSkillPackageFile(userID, skillID, filePath)
_ = skill
_ = version
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, 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.readSkillPackageFile(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) {
skill, err := s.visibleSkill(userID, skillID)
if err != nil {
return nil, err
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, err
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, err
}
bundle := &SkillPackageBundle{SkillID: skill.ID, Name: skill.Name, Description: skill.Description, VersionID: version.ID, Version: version.VersionLabel, ContentHash: version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(files))}
for _, file := range files {
content, err := s.readSkillArchiveEntry(version, 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) {
skill, err := s.visibleSkill(userID, skillID)
if err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" || utf8.RuneCountInString(query) > 120 {
return nil, BadAuthRequest("搜索关键词必须为 1-120 个字符")
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, err
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, err
}
needle := strings.ToLower(query)
results := make([]SkillFileSearchResult, 0, 20)
for _, file := range files {
if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes {
continue
}
content, err := s.readSkillArchiveEntry(version, 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: truncateRunes(strings.TrimSpace(line), 240)})
if len(results) >= 50 {
break
}
}
}
}
return results, nil
}
func (s *Service) readSkillPackageFile(userID string, skillID string, filePath string) (*model.Skill, *model.SkillVersion, *model.SkillFile, []byte, error) {
skill, err := s.visibleSkill(userID, skillID)
if err != nil {
return nil, nil, nil, nil, err
}
filePath, err = normalizeSkillPath(filePath)
if err != nil {
return nil, nil, nil, nil, err
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, nil, nil, nil, err
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, nil, nil, nil, err
}
for index := range files {
if files[index].Path != filePath {
continue
}
content, err := s.readSkillArchiveEntry(version, filePath)
return skill, version, &files[index], content, err
}
return nil, nil, nil, nil, BadAuthRequest("技能文件不存在")
}
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, BadAuthRequest("技能文件不存在")
}
func archiveFromMarkdown(data []byte, fallbackName string, fallbackDescription string) (skillPackageArchive, error) {
if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) {
return skillPackageArchive{}, 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{}, BadAuthRequest("ZIP 文件无法解析")
}
if len(reader.File) > maxSkillPackageFiles+64 {
return skillPackageArchive{}, 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{}, 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{}, BadAuthRequest("技能包包含重复文件路径")
}
if entry.UncompressedSize64 > maxSkillFileBytes {
return skillPackageArchive{}, 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{}, BadAuthRequest("技能包中单个文件不能超过 8MB")
}
total += int64(len(content))
if total > maxSkillPackageBytes {
return skillPackageArchive{}, 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, 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, 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, BadAuthRequest("技能包中缺少 SKILL.md")
}
if len(skillRoots) > 1 {
return nil, 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, BadAuthRequest("技能包文件数量不能超过 512 个")
}
return files, nil
}
func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) {
if _, ok := files["SKILL.md"]; !ok {
return skillPackageArchive{}, BadAuthRequest("技能包入口必须是 SKILL.md")
}
if metadata.Name == "" {
return skillPackageArchive{}, BadAuthRequest("SKILL.md 缺少 name,且无法从标题推断")
}
if metadata.Description == "" {
return skillPackageArchive{}, BadAuthRequest("SKILL.md 缺少 description,且无法从正文推断")
}
if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 {
return skillPackageArchive{}, BadAuthRequest("技能名称或简介超过长度限制")
}
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 skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hex.EncodeToString(hash.Sum(nil)), 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 = truncateRunes(strings.Join(paragraph, " "), 500)
}
metadata.Name = truncateRunes(strings.TrimSpace(metadata.Name), 80)
metadata.Description = truncateRunes(strings.TrimSpace(metadata.Description), 500)
metadata.Version = truncateRunes(strings.TrimSpace(metadata.Version), 64)
return metadata
}
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{}, BadAuthRequest("请输入公开的 https://github.com 仓库地址")
}
parts := strings.Split(strings.Trim(target.Path, "/"), "/")
if len(parts) < 2 {
return githubSkillSpec{}, 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{}, BadAuthRequest("GitHub 地址必须指向仓库根目录或 tree 子目录")
}
if spec.Owner == "" || spec.Repo == "" {
return githubSkillSpec{}, 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", 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{}, "", "", "", 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", 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 "", BadAuthRequest("技能文件路径无效")
}
clean := path.Clean(value)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") || strings.HasPrefix(clean, ".git/") || clean == ".git" {
return "", BadAuthRequest("技能文件路径越界或包含禁止目录")
}
if utf8.RuneCountInString(clean) > 1000 {
return "", 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)
}