package service import ( "context" "errors" "fmt" "github.com/Tencent/WeKnora/internal/accesspolicy" "path/filepath" "slices" "strings" "time" "unicode" "unicode/utf8" "github.com/Tencent/WeKnora/internal/agent/skills" "github.com/Tencent/WeKnora/internal/agent/token" "github.com/Tencent/WeKnora/internal/application/repository" apperrors "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/im" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) const MaxInstructionSkillBytes = 256 << 10 const maxInstructionSkillTokens = 16000 const maxInstructionSkillBindings = 32 // InstructionSkillService reuses the native catalog for non-executable rules. // Markdown imports are private to their creator; administrators may inspect // them. Native executable skills keep their existing workspace sharing policy. type InstructionSkillService struct { db *gorm.DB agents interfaces.CustomAgentService } func NewInstructionSkillService(db *gorm.DB, agents interfaces.CustomAgentService) *InstructionSkillService { return &InstructionSkillService{db: db, agents: agents} } type InstructionSkillView struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Version string `json:"version"` Instructions string `json:"instructions,omitempty"` CreatedBy string `json:"created_by"` VersionSourceID string `json:"version_source_id,omitempty"` CreatorName string `json:"creator_name,omitempty"` InstructionOnly bool `json:"instruction_only"` Visibility string `json:"visibility"` Enabled bool `json:"enabled"` UpdatedAt time.Time `json:"updated_at"` } func instructionSkillView(ctx context.Context, row types.TenantSkillCatalogEntity, body bool) InstructionSkillView { row.EnsureInstructionDefaults() v := InstructionSkillView{ID: row.ID, Name: row.Name, Description: row.Description, Version: row.Version, CreatedBy: row.CreatedBy, InstructionOnly: true, UpdatedAt: row.UpdatedAt} v.Visibility, v.Enabled = row.Visibility, row.Enabled if snapshot := accesspolicy.FromContext(ctx); snapshot != nil { if global, managed := snapshot.Publications["skills:"+row.ID]; managed { v.Visibility = "private" if global { v.Visibility = "global" } } } v.VersionSourceID = row.VersionSourceID if body { v.Instructions = row.Instructions } return v } func instructionIdentity(ctx context.Context, write bool) (uint64, string, bool, error) { tenant, ok := types.TenantIDFromContext(ctx) user, userOK := types.UserIDFromContext(ctx) role := types.TenantRoleFromContext(ctx) floor := types.TenantRoleViewer if write { floor = types.TenantRoleContributor } if !ok || tenant == 0 || !userOK || user == "" || types.IsSyntheticUserID(user) || !role.HasPermission(floor) { return 0, "", false, apperrors.NewForbiddenError("没有技能操作权限") } return tenant, user, assetAdministrator(ctx), nil } func (s *InstructionSkillService) List(ctx context.Context) ([]InstructionSkillView, error) { tenant, _, _, err := instructionIdentity(ctx, false) if err != nil { return nil, err } var rows []types.TenantSkillCatalogEntity q := s.db.WithContext(ctx).Where("tenant_id = ? AND instruction_only = ?", tenant, true) if err = q.Order("updated_at DESC").Find(&rows).Error; err != nil { return nil, err } out := make([]InstructionSkillView, 0, len(rows)) for _, row := range rows { if !instructionSkillReadable(ctx, &row) { continue } out = append(out, instructionSkillView(ctx, row, false)) } s.fillCreatorNames(ctx, out) return out, nil } func (s *InstructionSkillService) Get(ctx context.Context, id string) (*InstructionSkillView, error) { tenant, _, _, err := instructionIdentity(ctx, false) if err != nil { return nil, err } var row types.TenantSkillCatalogEntity q := s.db.WithContext(ctx).Where("id = ? AND tenant_id = ? AND instruction_only = ?", id, tenant, true) if err = q.First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.NewNotFoundError("技能不存在或无权访问") } return nil, err } if !instructionSkillReadable(ctx, &row) { return nil, apperrors.NewNotFoundError("技能不存在或无权访问") } v := instructionSkillView(ctx, row, true) views := []InstructionSkillView{v} s.fillCreatorNames(ctx, views) v = views[0] return &v, nil } func (s *InstructionSkillService) fillCreatorNames(ctx context.Context, views []InstructionSkillView) { ids := make([]string, 0, len(views)) for _, view := range views { if view.CreatedBy != "" { ids = append(ids, view.CreatedBy) } } if len(ids) == 0 { return } var users []struct { ID string Username string } if err := s.db.WithContext(ctx).Model(&types.User{}).Select("id, username").Where("id IN ?", ids).Find(&users).Error; err != nil { return } names := map[string]string{} for _, user := range users { names[user.ID] = user.Username } for i := range views { views[i].CreatorName = names[views[i].CreatedBy] } } func parseInstructionMarkdown(filename, name, description string, content []byte) (*types.TenantSkillCatalogEntity, error) { if !strings.EqualFold(filepath.Ext(filename), ".md") || len(content) == 0 || len(content) > MaxInstructionSkillBytes || !utf8.Valid(content) || strings.ContainsRune(string(content), 0) { return nil, apperrors.NewBadRequestError("请上传不超过 256 KB 的 UTF-8 Markdown(.md)文件") } text := strings.TrimSpace(strings.TrimPrefix(string(content), "\ufeff")) body, version := text, "" if strings.HasPrefix(text, "---\n") || strings.HasPrefix(text, "---\r\n") { parsed, err := skills.ParseSkillFile(text) if err != nil { return nil, apperrors.NewBadRequestError("技能 Markdown 的头部格式无效:" + err.Error()) } body = parsed.Instructions if strings.TrimSpace(name) == "" { name = parsed.Name } if strings.TrimSpace(description) == "" { description = parsed.Description } version, err = parseSkillBundleVersion(text) if err != nil { return nil, apperrors.NewBadRequestError("技能版本格式无效") } } name = strings.TrimSpace(name) if name == "" { name = strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) } if name == "" || len([]rune(name)) > 100 || strings.IndexFunc(name, unicode.IsControl) >= 0 || strings.ContainsAny(name, `/\`) { return nil, apperrors.NewBadRequestError("技能名称必填、最多 100 字且不能包含路径或控制字符") } body = strings.TrimSpace(body) if body == "" { return nil, apperrors.NewBadRequestError("技能正文不能为空") } if len([]rune(description)) > 1000 || len(version) > 64 { return nil, apperrors.NewBadRequestError("技能说明或版本过长") } estimator, err := token.NewEstimator() if err != nil { return nil, err } if estimator.EstimateString(body) > maxInstructionSkillTokens { return nil, apperrors.NewBadRequestError("技能正文过长,请控制在约 16000 Token 以内") } return &types.TenantSkillCatalogEntity{Name: name, Description: strings.TrimSpace(description), Version: version, Instructions: body, InstructionOnly: true, BundleSHA256: skillArchiveSHA256(content)}, nil } func (s *InstructionSkillService) Import(ctx context.Context, filename, name, description string, content []byte) (*InstructionSkillView, error) { tenant, user, _, err := instructionIdentity(ctx, true) if err != nil { return nil, err } row, err := parseInstructionMarkdown(filename, name, description, content) if err != nil { return nil, err } row.ID, row.TenantID, row.CreatedBy = uuid.NewString(), tenant, user row.EnsureInstructionDefaults() err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing types.TenantSkillCatalogEntity err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ? AND created_by = ? AND name = ? AND instruction_only = ? AND version_source_id = ''", tenant, user, row.Name, true).First(&existing).Error if errors.Is(err, gorm.ErrRecordNotFound) { return tx.Create(row).Error } if err != nil { return err } row.ID, row.CreatedBy, row.CreatedAt = existing.ID, existing.CreatedBy, existing.CreatedAt existing.EnsureInstructionDefaults() row.Visibility, row.Enabled = existing.Visibility, existing.Enabled row.UpdatedAt = time.Now() return tx.Model(&existing).Updates(map[string]any{"description": row.Description, "version": row.Version, "instructions": row.Instructions, "bundle_sha256": row.BundleSHA256, "updated_at": row.UpdatedAt}).Error }) if err != nil { if isUniqueViolation(err) { return nil, apperrors.NewConflictError("同名技能已被创建,请刷新列表后重试") } return nil, err } v := instructionSkillView(ctx, *row, true) views := []InstructionSkillView{v} s.fillCreatorNames(ctx, views) v = views[0] return &v, nil } // Delete locks the definition before its references, matching Mount's lock // order. Detachment and deletion commit together; denied or failed deletions // leave every Agent configuration intact. func (s *InstructionSkillService) Delete(ctx context.Context, id string, detach bool) error { tenant, user, admin, err := instructionIdentity(ctx, true) if err != nil { return err } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var skill types.TenantSkillCatalogEntity q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ? AND instruction_only = ?", id, tenant, true) if !admin { q = q.Where("created_by = ?", user) } if err := q.First(&skill).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return apperrors.NewNotFoundError("技能不存在或无权删除") } return err } // Read/lock tenant Agent rows in stable order. The skill lock prevents // new mount requests from racing this reference scan. var agents []types.CustomAgent if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ?", tenant).Order("id").Find(&agents).Error; err != nil { return err } ids := []string{} for _, a := range agents { if slices.Contains(a.Config.InstructionSkillIDs, id) { ids = append(ids, a.ID) } } if len(ids) > 0 && !detach { return apperrors.NewConflictError("技能已挂载到 Agent,可一键解绑全部关联后删除").WithDetails(map[string]any{"agent_ids": ids, "can_detach": true}) } for _, a := range agents { if !slices.Contains(a.Config.InstructionSkillIDs, id) { continue } a.Config.InstructionSkillIDs = slices.DeleteFunc(slices.Clone(a.Config.InstructionSkillIDs), func(selected string) bool { return selected == id }) if err := tx.Model(&a).Updates(map[string]any{"config": a.Config, "updated_at": time.Now()}).Error; err != nil { return err } } return tx.Delete(&skill).Error }) } type InstructionSkillMountRequest struct { AgentIDs []string `json:"agent_ids"` SkillIDs []string `json:"skill_ids"` Mode string `json:"mode"` // add (default), remove, replace } type InstructionSkillMountResult struct { AgentID string `json:"agent_id"` SkillIDs []string `json:"instruction_skill_ids"` UpdatedAt time.Time `json:"updated_at"` } func (s *InstructionSkillService) Mount(ctx context.Context, req InstructionSkillMountRequest) ([]InstructionSkillMountResult, error) { tenant, user, admin, err := instructionIdentity(ctx, true) if err != nil { return nil, err } agentIDs, skillIDs := dedupPreservingOrder(req.AgentIDs), dedupPreservingOrder(req.SkillIDs) if len(agentIDs) == 0 || len(agentIDs) > 100 || len(skillIDs) > maxInstructionSkillBindings { return nil, apperrors.NewBadRequestError("请选择 1–100 个 Agent,每个 Agent 最多挂载 32 个技能") } if req.Mode == "" { req.Mode = "add" } if req.Mode != "add" && req.Mode != "remove" && req.Mode != "replace" { return nil, apperrors.NewBadRequestError("挂载方式应为 add、remove 或 replace") } if req.Mode != "replace" && len(skillIDs) == 0 { return nil, apperrors.NewBadRequestError("请选择技能") } // Use the native service's visibility guard (including private digital-human // Agents) before acquiring row locks. The write ownership is rechecked below. for _, id := range agentIDs { a, err := s.agents.GetAgentByID(ctx, id) if err != nil || a == nil || a.TenantID != tenant || (!admin && a.CreatedBy != user) { return nil, apperrors.NewForbiddenError("所选 Agent 不存在或没有修改权限") } } slices.Sort(agentIDs) out := make([]InstructionSkillMountResult, 0, len(agentIDs)) err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if req.Mode != "remove" { var rows []types.TenantSkillCatalogEntity q := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id = ? AND id IN ? AND instruction_only = ?", tenant, skillIDs, true) if err := q.Find(&rows).Error; err != nil { return err } if len(rows) != len(skillIDs) { return apperrors.NewNotFoundError("部分技能不存在或不属于当前工作空间") } for _, row := range rows { row.EnsureInstructionDefaults() if !row.Enabled || !instructionSkillReadable(ctx, &row) { return apperrors.NewForbiddenError("所选文字技能已停用或无权使用") } } } for _, id := range agentIDs { // Native built-ins are virtual until first customized. Materialize // the tenant's default inside this transaction so bulk mounts stay // atomic and do not fail merely because no row existed yet. if types.IsBuiltinAgentID(id) { if !admin { return apperrors.NewForbiddenError("只有管理员可以修改内置 Agent") } builtin := types.GetBuiltinAgent(id, tenant) if builtin == nil { return apperrors.NewNotFoundError("内置 Agent 不存在") } builtin.EnsureDefaults() if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(builtin).Error; err != nil { return err } } var agent types.CustomAgent if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ? AND id = ?", tenant, id).First(&agent).Error; err != nil { return err } if !admin && agent.CreatedBy != user { return apperrors.NewForbiddenError("只能为自己创建的 Agent 挂载技能") } selected := slices.Clone(agent.Config.InstructionSkillIDs) switch req.Mode { case "add": selected = dedupPreservingOrder(append(selected, skillIDs...)) case "replace": selected = slices.Clone(skillIDs) case "remove": selected = slices.DeleteFunc(selected, func(id string) bool { return slices.Contains(skillIDs, id) }) } if len(selected) > maxInstructionSkillBindings { return apperrors.NewBadRequestError("每个 Agent 最多挂载 32 个文字技能") } if selected == nil { selected = []string{} } agent.Config.InstructionSkillIDs = selected // Validate the binding while allowing owners to repair a disabled // Agent. Execution still checks its actual persisted enabled state. validationAgent := agent validationAgent.EnsureDefaults() validationAgent.Enabled = true // Removing bindings must also work when another remaining skill has // been revoked or deleted. Execution still validates every dependency. if req.Mode != "remove" { if _, err := ResolveAgentInstructionPrompt(ctx, repository.NewTenantSkillRepository(tx), &validationAgent); err != nil { return err } } now := time.Now() if err := tx.Model(&agent).Updates(map[string]any{"config": agent.Config, "updated_at": now}).Error; err != nil { return err } out = append(out, InstructionSkillMountResult{AgentID: id, SkillIDs: selected, UpdatedAt: now}) } return nil }) if err != nil { return nil, err } return out, nil } type instructionCatalogReader interface { GetCatalog(context.Context, uint64, string) (*types.TenantSkillCatalogEntity, error) } // ResolveAgentInstructionPrompt is shared by native QA and other generation // surfaces. Call it after the native Agent access check; returned text belongs // in the system context and never grants executable tools or sandbox access. func ResolveAgentInstructionPrompt(ctx context.Context, repo instructionCatalogReader, agent *types.CustomAgent) (string, error) { if agent == nil { return "", nil } agent.EnsureDefaults() if !agent.Enabled { return "", apperrors.NewForbiddenError("Agent 已停用或无权使用") } if strings.Contains(agent.Config.SystemPrompt, "