Files
ttstd-infinite-canvas/backend/internal/skills/skill_packages_files.go
T

277 lines
10 KiB
Go

// 技能包内文件的浏览、读取、打包下载与全文检索。
package skills
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"path"
"strings"
"unicode/utf8"
"gorm.io/gorm"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
)
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
}