Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
674 lines
22 KiB
Go
674 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
)
|
|
|
|
// SkillCatalogInstallView is one installation of a catalog skill onto a sandbox.
|
|
type SkillCatalogInstallView struct {
|
|
SkillID string `json:"skill_id"`
|
|
SandboxConfigID string `json:"sandbox_config_id"`
|
|
SandboxConfigName string `json:"sandbox_config_name,omitempty"`
|
|
SandboxType string `json:"sandbox_type,omitempty"`
|
|
Status string `json:"status"`
|
|
Enabled bool `json:"enabled"`
|
|
Error string `json:"error,omitempty"`
|
|
BundleSHA256 string `json:"bundle_sha256,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// SkillCatalogView is a tenant skill definition plus its sandbox installations.
|
|
type SkillCatalogView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
BundleSHA256 string `json:"bundle_sha256,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Installations []SkillCatalogInstallView `json:"installations"`
|
|
InstructionOnly bool `json:"instruction_only"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
Visibility string `json:"visibility,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// ListCatalog returns every workspace skill definition and which sandbox
|
|
// configs currently carry an installation of it.
|
|
func (s *TenantSkillService) ListCatalog(
|
|
ctx context.Context, tenantID uint64,
|
|
) ([]SkillCatalogView, error) {
|
|
catalogs, err := s.skills.ListCatalogsByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
installs, err := s.skills.ListSkillsByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var configs []*types.TenantSandboxConfigEntity
|
|
if s.configs != nil {
|
|
var cfgErr error
|
|
configs, cfgErr = s.configs.ListByTenant(ctx, tenantID)
|
|
if cfgErr != nil {
|
|
return nil, cfgErr
|
|
}
|
|
}
|
|
configByID := make(map[string]*types.TenantSandboxConfigEntity, len(configs))
|
|
for _, cfg := range configs {
|
|
if cfg != nil {
|
|
configByID[cfg.ID] = cfg
|
|
}
|
|
}
|
|
|
|
byCatalog := make(map[string][]*types.TenantSkillEntity, len(catalogs))
|
|
unattached := make([]*types.TenantSkillEntity, 0)
|
|
for _, row := range installs {
|
|
if row == nil {
|
|
continue
|
|
}
|
|
if row.CatalogID != "" {
|
|
byCatalog[row.CatalogID] = append(byCatalog[row.CatalogID], row)
|
|
continue
|
|
}
|
|
unattached = append(unattached, row)
|
|
}
|
|
|
|
out := make([]SkillCatalogView, 0, len(catalogs)+len(unattached))
|
|
seenName := make(map[string]struct{}, len(catalogs))
|
|
seenCatalog := make(map[string]struct{}, len(catalogs))
|
|
for _, cat := range catalogs {
|
|
if cat == nil {
|
|
continue
|
|
}
|
|
seenCatalog[cat.ID] = struct{}{}
|
|
if cat.InstructionOnly {
|
|
if !instructionSkillReadable(ctx, cat) {
|
|
continue
|
|
}
|
|
}
|
|
if !cat.InstructionOnly {
|
|
seenName[cat.Name] = struct{}{}
|
|
}
|
|
out = append(out, catalogView(cat, byCatalog[cat.ID], configByID))
|
|
}
|
|
// Installs whose catalog row was deleted (or never existed) would otherwise
|
|
// vanish: they are not unattached (catalog_id is set) and they are not
|
|
// rendered under any live definition.
|
|
for catalogID, rows := range byCatalog {
|
|
if _, ok := seenCatalog[catalogID]; ok {
|
|
continue
|
|
}
|
|
unattached = append(unattached, rows...)
|
|
}
|
|
// Rows that predate catalog_id (or tests that insert installs directly)
|
|
// still need to show up as definitions so the settings page is complete.
|
|
for _, row := range unattached {
|
|
if _, exists := seenName[row.Name]; exists {
|
|
for i := range out {
|
|
if out[i].Name != row.Name || out[i].InstructionOnly {
|
|
continue
|
|
}
|
|
out[i].Installations = append(out[i].Installations, installView(row, configByID))
|
|
}
|
|
continue
|
|
}
|
|
seenName[row.Name] = struct{}{}
|
|
synthetic := &types.TenantSkillCatalogEntity{
|
|
ID: row.ID, TenantID: row.TenantID, Name: row.Name, Version: row.Version,
|
|
Description: row.Description, BundleSHA256: row.BundleSHA256,
|
|
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
|
}
|
|
out = append(out, catalogView(synthetic, []*types.TenantSkillEntity{row}, configByID))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func catalogView(
|
|
cat *types.TenantSkillCatalogEntity,
|
|
installs []*types.TenantSkillEntity,
|
|
configByID map[string]*types.TenantSandboxConfigEntity,
|
|
) SkillCatalogView {
|
|
cat.EnsureInstructionDefaults()
|
|
view := SkillCatalogView{
|
|
ID: cat.ID,
|
|
Name: cat.Name,
|
|
Version: cat.Version,
|
|
Description: cat.Description,
|
|
BundleSHA256: cat.BundleSHA256,
|
|
CreatedAt: cat.CreatedAt,
|
|
UpdatedAt: cat.UpdatedAt,
|
|
Installations: make([]SkillCatalogInstallView, 0, len(installs)),
|
|
InstructionOnly: cat.InstructionOnly,
|
|
CreatedBy: cat.CreatedBy,
|
|
Visibility: cat.Visibility,
|
|
Enabled: cat.Enabled || !cat.InstructionOnly,
|
|
}
|
|
for _, row := range installs {
|
|
view.Installations = append(view.Installations, installView(row, configByID))
|
|
}
|
|
return view
|
|
}
|
|
|
|
func installView(
|
|
row *types.TenantSkillEntity,
|
|
configByID map[string]*types.TenantSandboxConfigEntity,
|
|
) SkillCatalogInstallView {
|
|
v := SkillCatalogInstallView{
|
|
SkillID: row.ID,
|
|
SandboxConfigID: row.SandboxConfigID,
|
|
Status: row.Status,
|
|
Enabled: row.Enabled,
|
|
Error: row.Error,
|
|
BundleSHA256: row.BundleSHA256,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
if cfg := configByID[row.SandboxConfigID]; cfg != nil {
|
|
v.SandboxConfigName = cfg.Name
|
|
v.SandboxType = cfg.SandboxType
|
|
}
|
|
return v
|
|
}
|
|
|
|
// RegisterCatalogFromArchive records a skill definition without installing it
|
|
// onto any sandbox. Re-uploading the same name updates the stored bundle.
|
|
func (s *TenantSkillService) RegisterCatalogFromArchive(
|
|
ctx context.Context, tenantID uint64, archive []byte,
|
|
) (*types.TenantSkillCatalogEntity, error) {
|
|
bundle, err := ParseSkillBundle(archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.upsertCatalogFromBundle(ctx, tenantID, bundle, archive, true)
|
|
}
|
|
|
|
// RegisterCatalogFromSource fetches a public skill and records it in the catalog.
|
|
func (s *TenantSkillService) RegisterCatalogFromSource(
|
|
ctx context.Context, tenantID uint64, source string,
|
|
) (*types.TenantSkillCatalogEntity, error) {
|
|
bundle, archive, err := fetchNormalizedSkillBundle(ctx, source, s.sourceHTTP)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.upsertCatalogFromBundle(ctx, tenantID, bundle, archive, true)
|
|
}
|
|
|
|
// CatalogInstallResult is the per-sandbox outcome of one catalog install call.
|
|
// Partial success is a successful HTTP accept with Errors filled in, not a
|
|
// silent 202: the caller has to be able to tell which configs never started.
|
|
type CatalogInstallResult struct {
|
|
Installs map[string]string `json:"installs"`
|
|
Errors map[string]string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// InstallCatalogToConfigs copies a catalog skill onto each named sandbox using
|
|
// the existing image-install pipeline. Missing configs are skipped with an
|
|
// error; partial success is returned so the caller can show per-config status.
|
|
func (s *TenantSkillService) InstallCatalogToConfigs(
|
|
ctx context.Context, tenantID uint64, catalogID string, configIDs []string,
|
|
) (*CatalogInstallResult, error) {
|
|
catalog, err := s.resolveCatalog(ctx, tenantID, catalogID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if catalog.InstructionOnly {
|
|
return nil, apperrors.NewBadRequestError("文字技能无需安装沙箱,请直接挂载到 Agent")
|
|
}
|
|
archive, err := s.catalogBundleArchive(ctx, tenantID, catalog)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ids := uniqueNonEmptyStrings(configIDs)
|
|
if len(ids) == 0 {
|
|
return nil, apperrors.NewBadRequestError("at least one sandbox is required")
|
|
}
|
|
|
|
result := &CatalogInstallResult{
|
|
Installs: make(map[string]string, len(ids)),
|
|
Errors: make(map[string]string),
|
|
}
|
|
var firstErr error
|
|
for _, configID := range ids {
|
|
skillID, installErr := s.InstallSkill(ctx, tenantID, configID, archive)
|
|
if installErr != nil {
|
|
logger.Warnf(ctx, "[skill] install catalog %s onto config %s failed: %v",
|
|
catalogID, configID, installErr)
|
|
result.Errors[configID] = skillUserErrorMessage(installErr)
|
|
if firstErr == nil {
|
|
firstErr = installErr
|
|
}
|
|
continue
|
|
}
|
|
result.Installs[configID] = skillID
|
|
}
|
|
if len(result.Errors) == 0 {
|
|
result.Errors = nil
|
|
}
|
|
if len(result.Installs) == 0 {
|
|
return result, firstErr
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteCatalog removes a definition that has no remaining installations
|
|
// and drops the stored zip. Sandbox uninstalls never delete that zip.
|
|
func (s *TenantSkillService) DeleteCatalog(
|
|
ctx context.Context, tenantID uint64, catalogID string,
|
|
) error {
|
|
catalog, err := s.skills.GetCatalog(ctx, tenantID, catalogID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if catalog == nil {
|
|
return apperrors.NewNotFoundError("skill not found")
|
|
}
|
|
if catalog.InstructionOnly {
|
|
return apperrors.NewConflictError("请从文字技能列表删除,可一键解绑 Agent 后删除")
|
|
}
|
|
installs, err := s.skills.ListSkillsByCatalog(ctx, tenantID, catalogID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, row := range installs {
|
|
if row == nil {
|
|
continue
|
|
}
|
|
return apperrors.NewConflictError(
|
|
"remove this skill from every sandbox before deleting it from the catalog")
|
|
}
|
|
ref := strings.TrimSpace(catalog.BundleRef)
|
|
if err := s.skills.DeleteCatalog(ctx, tenantID, catalogID); err != nil {
|
|
return err
|
|
}
|
|
if ref != "" {
|
|
s.deleteBundleBestEffort(ctx, tenantID, ref)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *TenantSkillService) upsertCatalogFromBundle(
|
|
ctx context.Context, tenantID uint64, bundle *SkillBundle, archive []byte, requireStore bool,
|
|
) (*types.TenantSkillCatalogEntity, error) {
|
|
existing, err := s.skills.GetCatalogByName(ctx, tenantID, bundle.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := s.now()
|
|
if existing != nil {
|
|
if existing.InstructionOnly {
|
|
return nil, apperrors.NewConflictError("该名称用于文字技能,请为沙箱技能换一个名称")
|
|
}
|
|
stored, replaced, err := s.storeCatalogBundle(ctx, tenantID, existing, archive, requireStore)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !stored {
|
|
return existing, nil
|
|
}
|
|
keepOld, err := s.pinReplacedCatalogBundle(ctx, tenantID, existing, replaced)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
existing.Version = bundle.Version
|
|
existing.Description = bundle.Description
|
|
existing.Instructions = bundle.Instructions
|
|
existing.BundleSHA256 = bundle.SHA256
|
|
existing.UpdatedAt = now
|
|
if err := s.skills.UpdateCatalog(ctx, existing); err != nil {
|
|
// The stored definition still names the old archive, so leave that
|
|
// archive alone. The object just written is the leak, and it is the
|
|
// harmless one: the next re-register overwrites that same key.
|
|
// Installs that were pinned above still name the old archive, which
|
|
// is also what the uncommitted definition names.
|
|
return nil, err
|
|
}
|
|
if replaced.oldRef != "" && !replaced.sameDigest && !keepOld {
|
|
s.deleteBundleBestEffort(ctx, tenantID, replaced.oldRef)
|
|
}
|
|
return existing, nil
|
|
}
|
|
|
|
row := &types.TenantSkillCatalogEntity{
|
|
ID: uuid.NewString(), TenantID: tenantID, Name: bundle.Name,
|
|
Version: bundle.Version, Description: bundle.Description,
|
|
Instructions: bundle.Instructions,
|
|
CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
stored, _, err := s.storeCatalogBundle(ctx, tenantID, row, archive, requireStore)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if stored {
|
|
row.BundleSHA256 = bundle.SHA256
|
|
}
|
|
if err := s.skills.CreateCatalog(ctx, row); err != nil {
|
|
if !isSkillNameConflict(err) {
|
|
return nil, err
|
|
}
|
|
winner, lookupErr := s.skills.GetCatalogByName(ctx, tenantID, bundle.Name)
|
|
if lookupErr != nil {
|
|
return nil, lookupErr
|
|
}
|
|
if winner == nil {
|
|
return nil, err
|
|
}
|
|
// The row that lost the race is never written, and the object above is
|
|
// keyed by its ID, so nothing will ever name it again — the retry below
|
|
// stores its own copy under the winner's key.
|
|
if ref := strings.TrimSpace(row.BundleRef); ref != "" {
|
|
s.deleteBundleBestEffort(ctx, tenantID, ref)
|
|
}
|
|
return s.upsertCatalogFromBundle(ctx, tenantID, bundle, archive, requireStore)
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// catalogBundleReplacement is the archive a definition just stopped naming.
|
|
// Pinning has to happen before the catalog row is committed: once the row
|
|
// names the new object, an unpinned install with an empty BundleRef would
|
|
// follow it and lose the tree its image was built from.
|
|
type catalogBundleReplacement struct {
|
|
oldRef string
|
|
oldSHA string
|
|
sameDigest bool
|
|
}
|
|
|
|
// storeCatalogBundle writes the archive and reports whether the in-memory
|
|
// definition now names it. The caller must pin any replacement and only then
|
|
// commit the row; retiring the old object before that commit would delete
|
|
// bytes the stored definition still points at.
|
|
func (s *TenantSkillService) storeCatalogBundle(
|
|
ctx context.Context, tenantID uint64, catalog *types.TenantSkillCatalogEntity, archive []byte, requireStore bool,
|
|
) (bool, catalogBundleReplacement, error) {
|
|
var none catalogBundleReplacement
|
|
if catalog == nil || len(archive) == 0 {
|
|
return false, none, nil
|
|
}
|
|
digest := skillArchiveSHA256(archive)
|
|
oldRef := strings.TrimSpace(catalog.BundleRef)
|
|
oldSHA := strings.TrimSpace(catalog.BundleSHA256)
|
|
if oldRef != "" && oldSHA != "" && oldSHA == digest &&
|
|
s.catalogBundleStillHeld(ctx, tenantID, catalog) {
|
|
// Same bytes already on the definition. Installing onto another
|
|
// sandbox must not mint a second object.
|
|
return true, none, nil
|
|
}
|
|
fs, err := s.fileServiceForTenant(ctx, tenantID)
|
|
if err != nil {
|
|
if requireStore {
|
|
return false, none, err
|
|
}
|
|
logger.Warnf(ctx, "[skill] catalog bundle storage unavailable: %v", err)
|
|
return false, none, nil
|
|
}
|
|
ref, err := fs.SaveBytes(ctx, archive, tenantID,
|
|
fmt.Sprintf("tenant-skills/catalog/%s.zip", catalog.ID), false)
|
|
if err != nil {
|
|
if requireStore {
|
|
return false, none, fmt.Errorf("store catalog bundle: %w", err)
|
|
}
|
|
logger.Warnf(ctx, "[skill] catalog bundle store failed: %v", err)
|
|
return false, none, nil
|
|
}
|
|
catalog.BundleRef = ref
|
|
if oldRef == "" || oldRef == ref {
|
|
return true, none, nil
|
|
}
|
|
return true, catalogBundleReplacement{
|
|
oldRef: oldRef, oldSHA: oldSHA, sameDigest: oldSHA == digest,
|
|
}, nil
|
|
}
|
|
|
|
func (s *TenantSkillService) catalogBundleStillHeld(
|
|
ctx context.Context, tenantID uint64, catalog *types.TenantSkillCatalogEntity,
|
|
) bool {
|
|
if catalog == nil || strings.TrimSpace(catalog.BundleRef) == "" {
|
|
return false
|
|
}
|
|
want := strings.TrimSpace(catalog.BundleSHA256)
|
|
archive, ok := s.trySkillBundle(ctx, tenantID, &types.TenantSkillEntity{
|
|
Name: catalog.Name, BundleRef: catalog.BundleRef, BundleSHA256: catalog.BundleSHA256,
|
|
})
|
|
return ok && len(archive) > 0 && (want == "" || archiveMatchesSHA(archive, want))
|
|
}
|
|
|
|
// pinReplacedCatalogBundle stamps the previous archive onto installs that are
|
|
// still serving it. It must run before the catalog row is committed: a stamp
|
|
// that fails after that write leaves those installs following the new object
|
|
// while their SHA still names the old tree.
|
|
func (s *TenantSkillService) pinReplacedCatalogBundle(
|
|
ctx context.Context, tenantID uint64, catalog *types.TenantSkillCatalogEntity, replaced catalogBundleReplacement,
|
|
) (bool, error) {
|
|
if replaced.oldRef == "" || replaced.sameDigest {
|
|
return false, nil
|
|
}
|
|
if catalog != nil && replaced.oldRef == strings.TrimSpace(catalog.BundleRef) {
|
|
return false, nil
|
|
}
|
|
return s.pinInstallsToReplacedBundle(ctx, tenantID, catalog, replaced.oldRef, replaced.oldSHA)
|
|
}
|
|
|
|
// pinInstallsToReplacedBundle hands the archive a definition just replaced to
|
|
// the installs that are still serving it, and reports whether the object has to
|
|
// be kept.
|
|
//
|
|
// A definition is mutable; an installation is not. Re-registering a skill from
|
|
// a newer zip leaves every sandbox running the image built from the old one, so
|
|
// deleting those bytes would take read_skill and the admin file browser down
|
|
// for installs that are working perfectly. The row is what pins the object:
|
|
// once it names it, nothing else has to remember which version it was.
|
|
func (s *TenantSkillService) pinInstallsToReplacedBundle(
|
|
ctx context.Context, tenantID uint64, catalog *types.TenantSkillCatalogEntity, oldRef, oldSHA string,
|
|
) (bool, error) {
|
|
if catalog == nil || strings.TrimSpace(oldRef) == "" {
|
|
return false, nil
|
|
}
|
|
installs, err := s.skills.ListSkillsByCatalog(ctx, tenantID, catalog.ID)
|
|
if err != nil {
|
|
return true, fmt.Errorf("list installs of catalog %s before replacing its bundle: %w",
|
|
catalog.ID, err)
|
|
}
|
|
keep := false
|
|
for _, row := range installs {
|
|
if row == nil || strings.TrimSpace(row.BundleRef) != "" {
|
|
continue
|
|
}
|
|
if oldSHA == "" {
|
|
// The definition never recorded what those bytes were, so no row
|
|
// can be matched against them. Keep rather than orphan.
|
|
keep = true
|
|
continue
|
|
}
|
|
if strings.TrimSpace(row.BundleSHA256) != oldSHA {
|
|
continue
|
|
}
|
|
if err := s.updateSkillFields(ctx, row.TenantID, row.SandboxConfigID, row.ID,
|
|
func(e *types.TenantSkillEntity) { e.BundleRef = oldRef }); err != nil {
|
|
return true, fmt.Errorf("pin install %s to the replaced archive of catalog %s: %w",
|
|
row.ID, catalog.ID, err)
|
|
}
|
|
keep = true
|
|
}
|
|
return keep, nil
|
|
}
|
|
|
|
func skillUserErrorMessage(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
var app *apperrors.AppError
|
|
if errors.As(err, &app) && strings.TrimSpace(app.Message) != "" {
|
|
return app.Message
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func (s *TenantSkillService) resolveCatalog(
|
|
ctx context.Context, tenantID uint64, id string,
|
|
) (*types.TenantSkillCatalogEntity, error) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return nil, apperrors.NewNotFoundError("skill not found")
|
|
}
|
|
cat, err := s.skills.GetCatalog(ctx, tenantID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cat != nil {
|
|
if cat.InstructionOnly {
|
|
return nil, apperrors.NewNotFoundError("请通过文字技能接口访问此技能")
|
|
}
|
|
return cat, nil
|
|
}
|
|
installs, err := s.skills.ListSkillsByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var match *types.TenantSkillEntity
|
|
for _, row := range installs {
|
|
if row == nil {
|
|
continue
|
|
}
|
|
if row.ID == id || row.CatalogID == id {
|
|
match = row
|
|
break
|
|
}
|
|
}
|
|
if match == nil {
|
|
return nil, apperrors.NewNotFoundError("skill not found")
|
|
}
|
|
if cid := strings.TrimSpace(match.CatalogID); cid != "" && cid != id {
|
|
cat, err = s.skills.GetCatalog(ctx, tenantID, cid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cat != nil {
|
|
if cat.InstructionOnly {
|
|
return nil, apperrors.NewNotFoundError("请通过文字技能接口访问此技能")
|
|
}
|
|
return cat, nil
|
|
}
|
|
}
|
|
return catalogProjectionFromSkill(match), nil
|
|
}
|
|
|
|
func catalogProjectionFromSkill(row *types.TenantSkillEntity) *types.TenantSkillCatalogEntity {
|
|
return &types.TenantSkillCatalogEntity{
|
|
ID: row.ID, TenantID: row.TenantID, Name: row.Name, Version: row.Version,
|
|
Description: row.Description, Instructions: row.Instructions,
|
|
BundleRef: row.BundleRef, BundleSHA256: row.BundleSHA256,
|
|
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *TenantSkillService) ListCatalogFiles(
|
|
ctx context.Context, tenantID uint64, catalogID string,
|
|
) ([]SkillFileEntry, error) {
|
|
archive, err := s.loadCatalogArchive(ctx, tenantID, catalogID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return listSkillZipFiles(archive)
|
|
}
|
|
|
|
func (s *TenantSkillService) ReadCatalogFile(
|
|
ctx context.Context, tenantID uint64, catalogID, relativePath string,
|
|
) (*SkillFileContent, error) {
|
|
clean, err := safeSkillFilePath(relativePath)
|
|
if err != nil {
|
|
return nil, apperrors.NewBadRequestError(err.Error())
|
|
}
|
|
archive, err := s.loadCatalogArchive(ctx, tenantID, catalogID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := readSkillZipFile(archive, clean)
|
|
if err != nil {
|
|
if errors.Is(err, errSkillFileMissing) {
|
|
return nil, apperrors.NewNotFoundError("skill file not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return projectSkillFileContent(clean, body), nil
|
|
}
|
|
|
|
func (s *TenantSkillService) loadCatalogArchive(
|
|
ctx context.Context, tenantID uint64, catalogID string,
|
|
) ([]byte, error) {
|
|
catalog, err := s.resolveCatalog(ctx, tenantID, catalogID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.catalogBundleArchive(ctx, tenantID, catalog)
|
|
}
|
|
|
|
func (s *TenantSkillService) catalogBundleArchive(
|
|
ctx context.Context, tenantID uint64, catalog *types.TenantSkillCatalogEntity,
|
|
) ([]byte, error) {
|
|
if catalog == nil {
|
|
return nil, apperrors.NewNotFoundError("skill not found")
|
|
}
|
|
wantSHA := strings.TrimSpace(catalog.BundleSHA256)
|
|
tryRow := func(row *types.TenantSkillEntity) ([]byte, bool) {
|
|
if row == nil || strings.TrimSpace(row.BundleRef) == "" {
|
|
return nil, false
|
|
}
|
|
// Through the same cache the install rows use: the file drawer lists a
|
|
// tree and then opens files out of it, and a definition-owned archive
|
|
// is no cheaper to pull twice than a row-owned one.
|
|
archive, ok := s.trySkillBundle(ctx, tenantID, row)
|
|
if !ok || len(archive) == 0 {
|
|
return nil, false
|
|
}
|
|
if wantSHA != "" && !archiveMatchesSHA(archive, wantSHA) {
|
|
return nil, false
|
|
}
|
|
return archive, true
|
|
}
|
|
|
|
if ref := strings.TrimSpace(catalog.BundleRef); ref != "" {
|
|
if archive, ok := tryRow(&types.TenantSkillEntity{
|
|
Name: catalog.Name, BundleRef: catalog.BundleRef, BundleSHA256: catalog.BundleSHA256,
|
|
}); ok {
|
|
return archive, nil
|
|
}
|
|
}
|
|
installs, err := s.skills.ListSkillsByCatalog(ctx, tenantID, catalog.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range installs {
|
|
if archive, ok := tryRow(row); ok {
|
|
return archive, nil
|
|
}
|
|
}
|
|
all, err := s.skills.ListSkillsByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, row := range all {
|
|
if row == nil {
|
|
continue
|
|
}
|
|
if row.ID != catalog.ID && row.Name != catalog.Name {
|
|
continue
|
|
}
|
|
if archive, ok := tryRow(row); ok {
|
|
return archive, nil
|
|
}
|
|
}
|
|
return nil, apperrors.NewBadRequestError(
|
|
"the archive of this skill is no longer stored; add it again from the original bundle")
|
|
}
|