5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
224 lines
8.2 KiB
Go
224 lines
8.2 KiB
Go
package workbench
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type Skill struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Content string `json:"content"`
|
|
Variables []Variable `json:"variables"`
|
|
ToolIDs []string `json:"tool_ids"`
|
|
MCPServerIDs []string `json:"mcp_server_ids"`
|
|
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
|
Status string `json:"status"`
|
|
CategoryID *string `json:"category_id,omitempty"`
|
|
CategoryName string `json:"category_name"`
|
|
Tags []string `json:"tags"`
|
|
DepartmentIDs []string `json:"department_ids"`
|
|
Enabled bool `json:"enabled"`
|
|
Revision int64 `json:"revision"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type SkillInput struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Content string `json:"content"`
|
|
Variables []Variable `json:"variables"`
|
|
ToolIDs []string `json:"tool_ids"`
|
|
MCPServerIDs []string `json:"mcp_server_ids"`
|
|
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
|
Status string `json:"status"`
|
|
CategoryID *string `json:"category_id,omitempty"`
|
|
Tags []string `json:"tags"`
|
|
DepartmentIDs []string `json:"department_ids"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// SkillService manages packaged capabilities (skills): prompt content plus
|
|
// optional tool / MCP server / knowledge-base bindings, publishable to the
|
|
// marketplace and consumed by digital employees.
|
|
type SkillService struct {
|
|
assets *Service
|
|
}
|
|
|
|
func NewSkillService(assets *Service) *SkillService { return &SkillService{assets: assets} }
|
|
|
|
func (s *SkillService) validate(ctx context.Context, input *SkillInput, create bool) error {
|
|
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.Description = strings.TrimSpace(input.Description)
|
|
input.Content = strings.TrimSpace(input.Content)
|
|
if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 {
|
|
return errors.New("Skill 编码、名称或描述格式无效")
|
|
}
|
|
if create && (input.Content == "" || len(input.Content) > 100000) {
|
|
return errors.New("Skill 正文不能为空且最多 100000 字符")
|
|
}
|
|
switch input.Status {
|
|
case "", "draft":
|
|
input.Status = "draft"
|
|
case "published", "archived":
|
|
default:
|
|
return errors.New("无效的资源状态")
|
|
}
|
|
var err error
|
|
if input.Variables, err = validateVariables(input.Variables); err != nil {
|
|
return err
|
|
}
|
|
if input.ToolIDs, err = normalizeStrings(input.ToolIDs, 100); err != nil {
|
|
return err
|
|
}
|
|
if input.MCPServerIDs, err = normalizeStrings(input.MCPServerIDs, 100); err != nil {
|
|
return err
|
|
}
|
|
if input.KnowledgeBaseIDs, err = normalizeStrings(input.KnowledgeBaseIDs, 100); err != nil {
|
|
return err
|
|
}
|
|
if input.CategoryID != nil && strings.TrimSpace(*input.CategoryID) == "" {
|
|
input.CategoryID = nil
|
|
}
|
|
var categoryErr error
|
|
input.CategoryID, categoryErr = validCategoryID(ctx, s.assets.pool, input.CategoryID, "skill")
|
|
if categoryErr != nil {
|
|
return categoryErr
|
|
}
|
|
if input.Tags, err = normalizeStrings(input.Tags, 30); err != nil {
|
|
return err
|
|
}
|
|
if input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const skillSelect = `SELECT s.id::text,s.code,s.name,s.description,s.content,s.variables,s.tool_ids,s.mcp_server_ids,s.knowledge_base_ids,s.status,s.category_id::text,coalesce(c.name,''),s.tags,s.department_ids::text[],s.enabled,s.revision,s.created_at,s.updated_at FROM gateway.skills s LEFT JOIN gateway.marketplace_categories c ON c.id=s.category_id`
|
|
|
|
func scanSkill(row pgx.Row) (Skill, error) {
|
|
var s Skill
|
|
var variables []byte
|
|
err := row.Scan(&s.ID, &s.Code, &s.Name, &s.Description, &s.Content, &variables, &s.ToolIDs, &s.MCPServerIDs, &s.KnowledgeBaseIDs, &s.Status, &s.CategoryID, &s.CategoryName, &s.Tags, &s.DepartmentIDs, &s.Enabled, &s.Revision, &s.CreatedAt, &s.UpdatedAt)
|
|
if err != nil {
|
|
return s, mapNotFound(err)
|
|
}
|
|
_ = json.Unmarshal(variables, &s.Variables)
|
|
return s, nil
|
|
}
|
|
|
|
func (s *SkillService) List(ctx context.Context) ([]Skill, error) {
|
|
rows, err := s.assets.pool.Query(ctx, skillSelect+` ORDER BY s.updated_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Skill{}
|
|
for rows.Next() {
|
|
skill, err := scanSkill(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, skill)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *SkillService) Get(ctx context.Context, id string) (Skill, error) {
|
|
return scanSkill(s.assets.pool.QueryRow(ctx, skillSelect+` WHERE s.id=$1`, id))
|
|
}
|
|
|
|
func (s *SkillService) GetByCode(ctx context.Context, code string) (Skill, error) {
|
|
return scanSkill(s.assets.pool.QueryRow(ctx, skillSelect+` WHERE s.code=$1`, code))
|
|
}
|
|
|
|
// GetPublishedByCode returns a published, enabled skill by code.
|
|
func (s *SkillService) GetPublishedByCode(ctx context.Context, code string) (Skill, error) {
|
|
return scanSkill(s.assets.pool.QueryRow(ctx, skillSelect+` WHERE s.code=$1 AND s.status='published' AND s.enabled`, code))
|
|
}
|
|
|
|
func (s *SkillService) Save(ctx context.Context, id string, input SkillInput, actorID string, create bool) (Skill, error) {
|
|
if err := s.validate(ctx, &input, create); err != nil {
|
|
return Skill{}, err
|
|
}
|
|
tx, err := s.assets.pool.Begin(ctx)
|
|
if err != nil {
|
|
return Skill{}, err
|
|
}
|
|
defer rollback(ctx, tx)
|
|
if create {
|
|
id, err = newUUID()
|
|
if err != nil {
|
|
return Skill{}, err
|
|
}
|
|
variables, _ := json.Marshal(input.Variables)
|
|
_, err = tx.Exec(ctx, `INSERT INTO gateway.skills(id,code,name,description,content,variables,tool_ids,mcp_server_ids,knowledge_base_ids,status,category_id,tags,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, id, input.Code, input.Name, input.Description, input.Content, variables, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID)
|
|
} else {
|
|
variables, _ := json.Marshal(input.Variables)
|
|
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.skills SET code=$2,name=$3,description=$4,content=$5,variables=$6,tool_ids=$7,mcp_server_ids=$8,knowledge_base_ids=$9,status=$10,category_id=$11,tags=$12,department_ids=$13,enabled=$14,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.Content, variables, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled)
|
|
err = updateErr
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return Skill{}, ErrNotFound
|
|
}
|
|
}
|
|
if err != nil {
|
|
return Skill{}, err
|
|
}
|
|
event := "skill.updated"
|
|
if create {
|
|
event = "skill.created"
|
|
}
|
|
if err = emit(ctx, tx, event, "skill", id, actorID, nil); err != nil {
|
|
return Skill{}, err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return Skill{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *SkillService) Delete(ctx context.Context, id, actorID string) error {
|
|
tx, err := s.assets.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rollback(ctx, tx)
|
|
var used bool
|
|
if err = tx.QueryRow(ctx, `SELECT EXISTS(
|
|
SELECT 1 FROM gateway.digital_employees WHERE $1 = ANY(skill_ids)
|
|
UNION ALL SELECT 1 FROM gateway.marketplace_installations WHERE resource_type='skill' AND resource_id=$1
|
|
)`, id).Scan(&used); err != nil {
|
|
return err
|
|
}
|
|
if used {
|
|
return ErrConflict
|
|
}
|
|
tag, err := tx.Exec(ctx, `DELETE FROM gateway.skills WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if err = emit(ctx, tx, "skill.deleted", "skill", id, actorID, nil); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// Render applies the skill's variables to its content (same substitution as
|
|
// prompt rendering).
|
|
func (s *SkillService) Render(skill Skill, provided map[string]any) (string, error) {
|
|
return RenderPrompt(skill.Content, skill.Variables, provided)
|
|
}
|