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

277 lines
10 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 (
"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
}