5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
379 lines
15 KiB
Go
379 lines
15 KiB
Go
package workbench
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type DigitalEmployee struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Persona string `json:"persona"`
|
|
Model string `json:"model"`
|
|
SkillIDs []string `json:"skill_ids"`
|
|
ToolIDs []string `json:"tool_ids"`
|
|
MCPServerIDs []string `json:"mcp_server_ids"`
|
|
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
|
Temperature float64 `json:"temperature"`
|
|
RetrievalTopK int `json:"retrieval_top_k"`
|
|
MaxToolRounds int `json:"max_tool_rounds"`
|
|
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 DigitalEmployeeInput struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Persona string `json:"persona"`
|
|
Model string `json:"model"`
|
|
SkillIDs []string `json:"skill_ids"`
|
|
ToolIDs []string `json:"tool_ids"`
|
|
MCPServerIDs []string `json:"mcp_server_ids"`
|
|
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
|
Temperature float64 `json:"temperature"`
|
|
RetrievalTopK int `json:"retrieval_top_k"`
|
|
MaxToolRounds int `json:"max_tool_rounds"`
|
|
Status string `json:"status"`
|
|
CategoryID *string `json:"category_id,omitempty"`
|
|
Tags []string `json:"tags"`
|
|
DepartmentIDs []string `json:"department_ids"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type DigitalEmployeeRun struct {
|
|
ID string `json:"id"`
|
|
DigitalEmployee string `json:"digital_employee"`
|
|
RequestID string `json:"request_id"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
LatencyMS int64 `json:"latency_ms"`
|
|
RetrievalCount int `json:"retrieval_count"`
|
|
ToolCount int `json:"tool_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// DigitalEmployeeService manages composite digital employees. A digital
|
|
// employee bundles a persona, model, and bound skills / tools / MCP servers /
|
|
// knowledge bases; it is the end-user consumption point of the marketplace.
|
|
type DigitalEmployeeService struct {
|
|
assets *Service
|
|
skills *SkillService
|
|
tools *ToolService
|
|
mcpServers *MCPServerService
|
|
}
|
|
|
|
func NewDigitalEmployeeService(assets *Service, skills *SkillService, tools *ToolService, mcpServers *MCPServerService) *DigitalEmployeeService {
|
|
return &DigitalEmployeeService{assets: assets, skills: skills, tools: tools, mcpServers: mcpServers}
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) validate(ctx context.Context, input *DigitalEmployeeInput, create bool) error {
|
|
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.Description = strings.TrimSpace(input.Description)
|
|
input.Persona = strings.TrimSpace(input.Persona)
|
|
input.Model = strings.TrimSpace(input.Model)
|
|
if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 || len(input.Persona) > 100000 {
|
|
return errors.New("数字员工编码、名称、描述或人设格式无效")
|
|
}
|
|
if input.Model == "" {
|
|
return errors.New("必须指定模型")
|
|
}
|
|
if input.Temperature == 0 {
|
|
input.Temperature = 0.7
|
|
}
|
|
if input.Temperature < 0 || input.Temperature > 2 {
|
|
return errors.New("temperature 应在 0-2 之间")
|
|
}
|
|
if input.RetrievalTopK == 0 {
|
|
input.RetrievalTopK = 5
|
|
}
|
|
if input.RetrievalTopK < 1 || input.RetrievalTopK > 50 {
|
|
return errors.New("retrieval_top_k 应在 1-50 之间")
|
|
}
|
|
if input.MaxToolRounds == 0 {
|
|
input.MaxToolRounds = 5
|
|
}
|
|
if input.MaxToolRounds < 1 || input.MaxToolRounds > 20 {
|
|
return errors.New("max_tool_rounds 应在 1-20 之间")
|
|
}
|
|
switch input.Status {
|
|
case "", "draft":
|
|
input.Status = "draft"
|
|
case "published", "archived":
|
|
default:
|
|
return errors.New("无效的资源状态")
|
|
}
|
|
var err error
|
|
if input.SkillIDs, err = normalizeStrings(input.SkillIDs, 100); 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, "digital_employee")
|
|
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 s.validateBindings(ctx, input, false)
|
|
}
|
|
|
|
// validateBindings ensures every bound asset exists (and, when strict, is
|
|
// enabled) so a published digital employee never references a missing resource.
|
|
func (s *DigitalEmployeeService) validateBindings(ctx context.Context, input *DigitalEmployeeInput, strict bool) error {
|
|
for _, id := range input.SkillIDs {
|
|
skill, err := s.skills.Get(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("绑定的 Skill %s 不存在", id)
|
|
}
|
|
if strict && !skill.Enabled {
|
|
return fmt.Errorf("绑定的 Skill %s 未启用", skill.Code)
|
|
}
|
|
}
|
|
for _, id := range input.ToolIDs {
|
|
tool, err := s.tools.Get(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("绑定的工具 %s 不存在", id)
|
|
}
|
|
if strict && !tool.Enabled {
|
|
return fmt.Errorf("绑定的工具 %s 未启用", tool.Code)
|
|
}
|
|
}
|
|
for _, id := range input.MCPServerIDs {
|
|
server, err := s.mcpServers.Get(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("绑定的 MCP 服务器 %s 不存在", id)
|
|
}
|
|
if strict && !server.Enabled {
|
|
return fmt.Errorf("绑定的 MCP 服务器 %s 未启用", server.Code)
|
|
}
|
|
}
|
|
for _, id := range input.KnowledgeBaseIDs {
|
|
var exists bool
|
|
if err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.knowledge_bases WHERE id=$1)`, id).Scan(&exists); err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("绑定的知识库 %s 不存在", id)
|
|
}
|
|
if strict {
|
|
var enabled bool
|
|
if err := s.assets.pool.QueryRow(ctx, `SELECT enabled FROM gateway.knowledge_bases WHERE id=$1`, id).Scan(&enabled); err != nil {
|
|
return err
|
|
}
|
|
if !enabled {
|
|
return fmt.Errorf("绑定的知识库 %s 未启用", id)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const digitalEmployeeSelect = `SELECT d.id::text,d.code,d.name,d.description,d.persona,d.model,d.skill_ids,d.tool_ids,d.mcp_server_ids,d.knowledge_base_ids,d.temperature,d.retrieval_top_k,d.max_tool_rounds,d.status,d.category_id::text,coalesce(c.name,''),d.tags,d.department_ids::text[],d.enabled,d.revision,d.created_at,d.updated_at FROM gateway.digital_employees d LEFT JOIN gateway.marketplace_categories c ON c.id=d.category_id`
|
|
|
|
func scanDigitalEmployee(row pgx.Row) (DigitalEmployee, error) {
|
|
var d DigitalEmployee
|
|
err := row.Scan(&d.ID, &d.Code, &d.Name, &d.Description, &d.Persona, &d.Model, &d.SkillIDs, &d.ToolIDs, &d.MCPServerIDs, &d.KnowledgeBaseIDs, &d.Temperature, &d.RetrievalTopK, &d.MaxToolRounds, &d.Status, &d.CategoryID, &d.CategoryName, &d.Tags, &d.DepartmentIDs, &d.Enabled, &d.Revision, &d.CreatedAt, &d.UpdatedAt)
|
|
return d, mapNotFound(err)
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) List(ctx context.Context) ([]DigitalEmployee, error) {
|
|
rows, err := s.assets.pool.Query(ctx, digitalEmployeeSelect+` ORDER BY d.updated_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []DigitalEmployee{}
|
|
for rows.Next() {
|
|
item, err := scanDigitalEmployee(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) Get(ctx context.Context, id string) (DigitalEmployee, error) {
|
|
return scanDigitalEmployee(s.assets.pool.QueryRow(ctx, digitalEmployeeSelect+` WHERE d.id=$1`, id))
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) GetByCode(ctx context.Context, code string) (DigitalEmployee, error) {
|
|
return scanDigitalEmployee(s.assets.pool.QueryRow(ctx, digitalEmployeeSelect+` WHERE d.code=$1`, code))
|
|
}
|
|
|
|
// GetPublishedByCode returns a published, enabled digital employee by code.
|
|
func (s *DigitalEmployeeService) GetPublishedByCode(ctx context.Context, code string) (DigitalEmployee, error) {
|
|
return scanDigitalEmployee(s.assets.pool.QueryRow(ctx, digitalEmployeeSelect+` WHERE d.code=$1 AND d.status='published' AND d.enabled`, code))
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) Save(ctx context.Context, id string, input DigitalEmployeeInput, actorID string, create bool) (DigitalEmployee, error) {
|
|
if err := s.validate(ctx, &input, create); err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
tx, err := s.assets.pool.Begin(ctx)
|
|
if err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
defer rollback(ctx, tx)
|
|
if create {
|
|
id, err = newUUID()
|
|
if err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
_, err = tx.Exec(ctx, `INSERT INTO gateway.digital_employees(id,code,name,description,persona,model,skill_ids,tool_ids,mcp_server_ids,knowledge_base_ids,temperature,retrieval_top_k,max_tool_rounds,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,$16,$17,$18,$19)`, id, input.Code, input.Name, input.Description, input.Persona, input.Model, input.SkillIDs, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Temperature, input.RetrievalTopK, input.MaxToolRounds, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID)
|
|
} else {
|
|
tag, updateErr := tx.Exec(ctx, `UPDATE gateway.digital_employees SET code=$2,name=$3,description=$4,persona=$5,model=$6,skill_ids=$7,tool_ids=$8,mcp_server_ids=$9,knowledge_base_ids=$10,temperature=$11,retrieval_top_k=$12,max_tool_rounds=$13,status=$14,category_id=$15,tags=$16,department_ids=$17,enabled=$18,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.Persona, input.Model, input.SkillIDs, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Temperature, input.RetrievalTopK, input.MaxToolRounds, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled)
|
|
err = updateErr
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return DigitalEmployee{}, ErrNotFound
|
|
}
|
|
}
|
|
if err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
event := "digital_employee.updated"
|
|
if create {
|
|
event = "digital_employee.created"
|
|
}
|
|
if err = emit(ctx, tx, event, "digital_employee", id, actorID, nil); err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
// Publish revalidates all bindings (must exist and be enabled) and flips the
|
|
// status to published so the resource appears in the marketplace.
|
|
func (s *DigitalEmployeeService) Publish(ctx context.Context, id, actorID string) (DigitalEmployee, error) {
|
|
current, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
input := DigitalEmployeeInput{
|
|
Code: current.Code, Name: current.Name, Description: current.Description,
|
|
Persona: current.Persona, Model: current.Model,
|
|
SkillIDs: current.SkillIDs, ToolIDs: current.ToolIDs, MCPServerIDs: current.MCPServerIDs,
|
|
KnowledgeBaseIDs: current.KnowledgeBaseIDs, Temperature: current.Temperature,
|
|
RetrievalTopK: current.RetrievalTopK, MaxToolRounds: current.MaxToolRounds,
|
|
Status: "published", CategoryID: current.CategoryID, Tags: current.Tags,
|
|
DepartmentIDs: current.DepartmentIDs, Enabled: current.Enabled,
|
|
}
|
|
if err = s.validateBindings(ctx, &input, true); err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
if err = s.setStatus(ctx, id, actorID, "published"); err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
// Archive unpublishes a digital employee from the marketplace without deleting
|
|
// its configuration.
|
|
func (s *DigitalEmployeeService) Archive(ctx context.Context, id, actorID string) (DigitalEmployee, error) {
|
|
if err := s.setStatus(ctx, id, actorID, "archived"); err != nil {
|
|
return DigitalEmployee{}, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) setStatus(ctx context.Context, id, actorID, status string) error {
|
|
tx, err := s.assets.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rollback(ctx, tx)
|
|
tag, err := tx.Exec(ctx, `UPDATE gateway.digital_employees SET status=$2,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, status)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if err = emit(ctx, tx, "digital_employee."+status, "digital_employee", id, actorID, nil); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) 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.marketplace_installations WHERE resource_type='digital_employee' AND resource_id=$1)`, id).Scan(&used); err != nil {
|
|
return err
|
|
}
|
|
if used {
|
|
return ErrConflict
|
|
}
|
|
tag, err := tx.Exec(ctx, `DELETE FROM gateway.digital_employees WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if err = emit(ctx, tx, "digital_employee.deleted", "digital_employee", id, actorID, nil); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *DigitalEmployeeService) ListRuns(ctx context.Context, id string, limit int) ([]DigitalEmployeeRun, error) {
|
|
if limit < 1 {
|
|
limit = 50
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
rows, err := s.assets.pool.Query(ctx, `SELECT id::text,(SELECT code FROM gateway.digital_employees WHERE id=r.digital_employee_id),request_id,status,error,latency_ms,retrieval_count,tool_count,created_at FROM gateway.digital_employee_runs r WHERE digital_employee_id=$1 ORDER BY created_at DESC LIMIT $2`, id, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []DigitalEmployeeRun{}
|
|
for rows.Next() {
|
|
var run DigitalEmployeeRun
|
|
if err = rows.Scan(&run.ID, &run.DigitalEmployee, &run.RequestID, &run.Status, &run.Error, &run.LatencyMS, &run.RetrievalCount, &run.ToolCount, &run.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, run)
|
|
}
|
|
return items, rows.Err()
|
|
}
|