Files
ai-gateway-go/internal/workbench/digital_employees.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

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()
}