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