Files
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

503 lines
20 KiB
Go
Raw Permalink 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 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)
}