1030 lines
34 KiB
Go
1030 lines
34 KiB
Go
package skills
|
||
|
||
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/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 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, 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) {
|
||
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, 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.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, kernel.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: kernel.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, kernel.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, 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 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 := 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 = 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)
|
||
}
|