Files

503 lines
20 KiB
Go
Raw Permalink Normal View History

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, "<!-- weknora:digital-human:") {
guard, ok := repo.(interface {
CheckDigitalHumanAgentExecution(context.Context, *types.CustomAgent) error
})
if !ok {
return "", apperrors.NewForbiddenError("数字人授权校验不可用,请稍后重试")
}
if err := guard.CheckDigitalHumanAgentExecution(ctx, agent); err != nil {
return "", err
}
}
// Existing organization shares already authorize the Agent itself upstream.
// Without instruction skills, preserve that native execution path.
if len(agent.Config.InstructionSkillIDs) == 0 {
return "", nil
}
tenant, ok := types.TenantIDFromContext(ctx)
if !ok || tenant == 0 || agent.TenantID != tenant {
return "", apperrors.NewForbiddenError("文字技能不能跨工作空间读取")
}
user, userOK := types.UserIDFromContext(ctx)
verifiedIM := im.IsVerifiedAgentExecution(ctx, agent)
if !verifiedIM && !nativeAgentReadable(ctx, agent) && !nativeAgentExecutableDependency(ctx, agent) {
return "", apperrors.NewForbiddenError("Agent 已停用或无权使用")
}
if !verifiedIM && (!userOK || user == "" || types.IsSyntheticUserID(user)) {
return "", apperrors.NewForbiddenError("没有此 Agent 私有文字技能的使用权限")
}
ids := dedupPreservingOrder(agent.Config.InstructionSkillIDs)
if repo == nil || len(ids) > maxInstructionSkillBindings {
return "", apperrors.NewBadRequestError("Agent 的文字技能配置不可用")
}
var result strings.Builder
result.WriteString("\n\n## 已挂载的文字技能\n按以下规则执行本次任务;这些规则不授予额外工具、脚本或数据访问权限。\n")
for _, id := range ids {
row, err := repo.GetCatalog(ctx, tenant, id)
if err != nil {
return "", apperrors.NewForbiddenError("已挂载的文字技能不可用,请重新选择技能")
}
if row == nil || !row.InstructionOnly || row.TenantID != tenant {
return "", apperrors.NewBadRequestError("已挂载的文字技能已删除或无权使用,请重新选择技能")
}
row.EnsureInstructionDefaults()
if !row.Enabled || (row.Visibility != "global" && row.Visibility != "private") {
return "", apperrors.NewForbiddenError("已挂载的文字技能已停用或无权使用")
}
// Sharing an Agent permits executing its own private rules without
// exposing the rules as independently readable or mountable resources.
ownedDependency := row.CreatedBy != "" && row.CreatedBy == agent.CreatedBy
if !verifiedIM && !ownedDependency && !instructionSkillReadable(ctx, row) {
return "", apperrors.NewForbiddenError("已挂载私有文字技能的授权已撤销,请重新选择技能")
}
shared := accesspolicy.Readable(ctx, "skills", row.ID, false, false)
if !shared && row.Visibility == "private" && !ownedDependency {
return "", apperrors.NewForbiddenError("私有文字技能只能挂载到同一创建者的 Agent")
}
fmt.Fprintf(&result, "\n### %s\n%s\n", row.Name, row.Instructions)
}
estimator, err := token.NewEstimator()
if err != nil {
return "", err
}
if estimator.EstimateString(result.String()) > maxInstructionSkillTokens {
return "", apperrors.NewBadRequestError("挂载技能的合计正文过长,请减少技能或缩短规则,系统不会自动截断技能")
}
return result.String(), nil
}
func (s *InstructionSkillService) ResolveAgentInstructions(ctx context.Context, agent *types.CustomAgent) (string, error) {
return ResolveAgentInstructionPrompt(ctx, repository.NewTenantSkillRepository(s.db), agent)
}