Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/bootstrap/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ func initializeService(svc *app.Service) error {
svc.EnsureDefaultPromptTemplates,
svc.EnsureBuiltinProjectWorkflowTemplate,
svc.EnsureBuiltinSkills,
svc.SkillsDomain().SyncLocalSkillsAtStartup,
svc.EnsureSkillPackages,
}
for _, initialize := range initializers {
Expand Down
30 changes: 30 additions & 0 deletions backend/internal/handler/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,36 @@ func registerSkillRoutes(r *gin.RouterGroup, owners workspaceOwners, skillsDomai
})
}

r.GET("/skills/local-source", func(c *gin.Context) {
source, err := skillsDomain.LocalSkillSource()
if err != nil {
failService(c, err)
return
}
ok(c, source)
})
r.PUT("/skills/local-source", func(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
source, err := skillsDomain.SetLocalSkillSource(req.Enabled)
if err != nil {
failService(c, err)
return
}
ok(c, source)
})
r.POST("/skills/local-source/sync", func(c *gin.Context) {
if err := skillsDomain.SyncLocalSkills(); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"synced": true})
})
r.GET("/skills", func(c *gin.Context) {
user, err := currentUser(c, owners)
if err != nil {
Expand Down
18 changes: 18 additions & 0 deletions backend/internal/repository/skill_market.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ func (r *Repository) SkillVersionsByIDs(ids []string) ([]model.SkillVersion, err
err := r.db.Where("id IN ?", ids).Find(&rows).Error
return rows, err
}

// SkillScriptsPrefix 是技能标准里放可执行脚本的目录。
const SkillScriptsPrefix = "scripts/"

// SkillVersionsWithScripts 返回这些版本里带脚本的版本。
func (r *Repository) SkillVersionsWithScripts(ids []string) (map[string]bool, error) {
result := make(map[string]bool, len(ids))
if len(ids) == 0 {
return result, nil
}
var rows []string
err := r.db.Model(&model.SkillFile{}).Distinct("skill_version_id").Where("skill_version_id IN ? AND path LIKE ?", ids, SkillScriptsPrefix+"%").Pluck("skill_version_id", &rows).Error
for _, id := range rows {
result[id] = true
}
return result, err
}

func (r *Repository) SwitchSkillVersion(userID, skillID, versionID, instruction, name, description, sourceURL string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var s model.Skill
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/skills/agent_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)

// Pin is the exact installed package selected by the user for a business round.
Expand Down Expand Up @@ -116,7 +117,7 @@ func (s *Service) AgentVersion(userID string, pin Pin) (*AgentResource, error) {
result := &AgentResource{Pin: pin, Name: skill.Name, Description: skill.Description, Version: version.VersionLabel, Files: []SkillPackageFileItem{}, UnsupportedCapabilities: []string{}}
for _, file := range files {
result.Files = append(result.Files, skillFileItem(file))
if strings.HasPrefix(file.Path, "scripts/") {
if strings.HasPrefix(file.Path, repository.SkillScriptsPrefix) {
result.UnsupportedCapabilities = []string{"script_execution"}
}
}
Expand Down
109 changes: 109 additions & 0 deletions backend/internal/skills/local_skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package skills

import (
"encoding/json"
"errors"
"log"
"os"
"path/filepath"
"strings"

"infinite-canvas/backend/internal/model"
)

const (
localSkillsSettingKey = "local_skills_directory"
localSkillsSourceURL = "local://agents-skills"
localSkillsDirectory = "~/.agents/skills"
)

// LocalSkillSource 是技能库读取本机技能目录的开关。其他 AI 工具把技能装在这个目录里,默认一起用。
type LocalSkillSource struct {
Enabled bool `json:"enabled"`
Directory string `json:"directory"`
}

func (s *Service) LocalSkillSource() (*LocalSkillSource, error) {
enabled, err := s.localSkillsEnabled()
if err != nil {
return nil, err
}
return &LocalSkillSource{Enabled: enabled, Directory: localSkillsDirectory}, nil
}

// SetLocalSkillSource 保存开关并立刻同步:打开时读进来,关闭时把读进来的技能从技能库拿掉。
// 只动技能库里的记录,不碰用户电脑上的目录。
func (s *Service) SetLocalSkillSource(enabled bool) (*LocalSkillSource, error) {
value, err := json.Marshal(enabled)
if err != nil {
return nil, err
}
if err := s.repo.SaveSystemSetting(&model.SystemSetting{Key: localSkillsSettingKey, ValueJSON: string(value)}); err != nil {
return nil, err
}
if err := s.SyncLocalSkills(); err != nil {
return nil, err
}
return s.LocalSkillSource()
}

func (s *Service) localSkillsEnabled() (bool, error) {
setting, err := s.repo.LookupSystemSetting(localSkillsSettingKey)
if err != nil || setting == nil {
return setting == nil, err
}
var enabled bool
if err := json.Unmarshal([]byte(setting.ValueJSON), &enabled); err != nil {
return false, err
}
return enabled, nil
}

// SyncLocalSkills 让技能库和本机技能目录保持一致。启动时和打开技能库时各跑一次。
func (s *Service) SyncLocalSkills() error {
if s == nil || s.repo == nil || s.repo.DB() == nil {
return nil
}
enabled, err := s.localSkillsEnabled()
if err != nil {
return err
}
var dirs []string
if enabled {
home, err := os.UserHomeDir()
if err != nil {
return errors.New("找不到本机用户目录")
}
dirs = localSkillDirs(filepath.Join(home, ".agents", "skills"))
}
return s.syncDirectorySkills(skillDirSource{
sourceType: skillSourceTypeLocal, sourceURL: localSkillsSourceURL, idPrefix: "local-", ownerFallback: "local",
authorName: "本机", fallbackDescription: "本机技能", private: true, skipBroken: true,
}, dirs)
}

// SyncLocalSkillsAtStartup 不让本机目录的问题挡住应用启动。
func (s *Service) SyncLocalSkillsAtStartup() error {
if err := s.SyncLocalSkills(); err != nil {
log.Printf("读取本机技能失败:%v", err)
}
return nil
}

// localSkillDirs 列出目录下的每个技能。技能管理工具常用软链接把技能放进来,这里跟着链接读真实目录。
func localSkillDirs(root string) []string {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
dirs := make([]string, 0, len(entries))
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".") {
continue
}
if dir, err := filepath.EvalSymlinks(filepath.Join(root, entry.Name())); err == nil {
dirs = append(dirs, dir)
}
}
return dirs
}
Loading
Loading