chore: import clean Agent Desk snapshot (2/7)

This commit is contained in:
2026-10-16 11:57:21 +00:00
parent 79176541c7
commit 89c5231c4c
461 changed files with 60092 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
package services
import (
"context"
"fmt"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/errorsx"
)
var AgentEvaluationService = newAgentEvaluationService()
var AgentEvaluationRunHook func(context.Context, request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error)
type agentEvaluationService struct{}
func newAgentEvaluationService() *agentEvaluationService { return &agentEvaluationService{} }
func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
if req.AIAgentID <= 0 {
return nil, errorsx.InvalidParam("ai agent id is required")
}
if len(req.Cases) == 0 {
return nil, errorsx.InvalidParam("evaluation cases are required")
}
if len(req.Cases) > 100 {
return nil, errorsx.InvalidParam("evaluation case limit exceeded")
}
if AgentEvaluationRunHook == nil {
return nil, fmt.Errorf("agent evaluation runner is not initialized")
}
return AgentEvaluationRunHook(ctx, req)
}

View File

@@ -0,0 +1,26 @@
package services
import (
"context"
"testing"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
)
func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) {
previous := AgentEvaluationRunHook
t.Cleanup(func() { AgentEvaluationRunHook = previous })
called := false
AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
called = true
return &response.AgentEvaluationReportResponse{Total: len(req.Cases)}, nil
}
result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}})
if err != nil || !called || result.Total != 1 {
t.Fatalf("result=%#v called=%t err=%v", result, called, err)
}
if _, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{}); err == nil {
t.Fatal("expected invalid request")
}
}

View File

@@ -0,0 +1,207 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"strings"
"time"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
)
var AgentProfileService = newAgentProfileService()
func newAgentProfileService() *agentProfileService {
return &agentProfileService{}
}
type agentProfileService struct {
}
func (s *agentProfileService) Get(id int64) *models.AgentProfile {
return repositories.AgentProfileRepository.Get(sqls.DB(), id)
}
func (s *agentProfileService) Take(where ...interface{}) *models.AgentProfile {
return repositories.AgentProfileRepository.Take(sqls.DB(), where...)
}
func (s *agentProfileService) Find(cnd *sqls.Cnd) []models.AgentProfile {
return repositories.AgentProfileRepository.Find(sqls.DB(), cnd)
}
func (s *agentProfileService) FindOne(cnd *sqls.Cnd) *models.AgentProfile {
return repositories.AgentProfileRepository.FindOne(sqls.DB(), cnd)
}
func (s *agentProfileService) FindPageByParams(params *params.QueryParams) (list []models.AgentProfile, paging *sqls.Paging) {
return repositories.AgentProfileRepository.FindPageByParams(sqls.DB(), params)
}
func (s *agentProfileService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentProfile, paging *sqls.Paging) {
return repositories.AgentProfileRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *agentProfileService) Count(cnd *sqls.Cnd) int64 {
return repositories.AgentProfileRepository.Count(sqls.DB(), cnd)
}
func (s *agentProfileService) GetByUserID(userID int64) *models.AgentProfile {
if userID <= 0 {
return nil
}
return repositories.AgentProfileRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("user_id", userID))
}
func (s *agentProfileService) GetUserIDsByTeamID(teamID int64) []int64 {
if teamID <= 0 {
return nil
}
list := s.Find(sqls.NewCnd().Eq("team_id", teamID))
if len(list) == 0 {
return nil
}
result := make([]int64, 0, len(list))
for _, item := range list {
if item.UserID > 0 {
result = append(result, item.UserID)
}
}
return result
}
// GetDispatchAgents 获取可用于分配会话的客服
func (s *agentProfileService) GetDispatchAgents(teamIds []int64) []models.AgentProfile {
return AgentProfileService.Find(sqls.NewCnd().
In("team_id", teamIds).
Eq("status", enums.StatusOk).
Eq("auto_assign_enabled", true).
Eq("service_status", enums.ServiceStatusIdle))
}
func (s *agentProfileService) CreateAgentProfile(req request.CreateAgentProfileRequest, operator *dto.AuthPrincipal) (*models.AgentProfile, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
item, err := s.buildProfileModel(0, req)
if err != nil {
return nil, err
}
item.AuditFields = utils.BuildAuditFields(operator)
if err := repositories.AgentProfileRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
s.dispatchPendingConversationsIfEligible(item)
return item, nil
}
func (s *agentProfileService) UpdateAgentProfile(req request.UpdateAgentProfileRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(req.ID)
if current == nil {
return errorsx.InvalidParamI18n("error.e0164")
}
item, err := s.buildProfileModel(req.ID, req.CreateAgentProfileRequest)
if err != nil {
return err
}
if err := repositories.AgentProfileRepository.Updates(sqls.DB(), req.ID, map[string]any{
"user_id": item.UserID,
"team_id": item.TeamID,
"agent_code": item.AgentCode,
"display_name": item.DisplayName,
"avatar": item.Avatar,
"service_status": item.ServiceStatus,
"max_concurrent_count": item.MaxConcurrentCount,
"priority_level": item.PriorityLevel,
"auto_assign_enabled": item.AutoAssignEnabled,
"receive_offline_message": item.ReceiveOfflineMessage,
"remark": item.Remark,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}); err != nil {
return err
}
s.dispatchPendingConversationsIfEligible(item)
return nil
}
func (s *agentProfileService) DeleteAgentProfile(id int64) error {
current := s.Get(id)
if current == nil {
return errorsx.InvalidParamI18n("error.e0164")
}
repositories.AgentProfileRepository.Delete(sqls.DB(), id)
return nil
}
func (s *agentProfileService) buildProfileModel(id int64, req request.CreateAgentProfileRequest) (*models.AgentProfile, error) {
if req.UserID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0325")
}
if UserService.Get(req.UserID) == nil {
return nil, errorsx.InvalidParamI18n("error.e0127")
}
if req.TeamID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0328")
}
if AgentTeamService.Get(req.TeamID) == nil {
return nil, errorsx.InvalidParamI18n("error.e0205")
}
req.AgentCode = strings.TrimSpace(req.AgentCode)
req.DisplayName = strings.TrimSpace(req.DisplayName)
if req.AgentCode == "" || req.DisplayName == "" {
return nil, errorsx.InvalidParamI18n("error.e0162")
}
if exists := s.Take("user_id = ? AND id <> ?", req.UserID, id); exists != nil {
return nil, errorsx.InvalidParamI18n("error.e0314")
}
if exists := s.Take("agent_code = ? AND id <> ?", req.AgentCode, id); exists != nil {
return nil, errorsx.InvalidParamI18n("error.e0163")
}
if !enums.IsValidServiceStatus(req.ServiceStatus) {
return nil, errorsx.InvalidParamI18n("error.e0165")
}
if req.MaxConcurrentCount < 0 {
return nil, errorsx.InvalidParamI18n("error.e0229")
}
return &models.AgentProfile{
UserID: req.UserID,
TeamID: req.TeamID,
AgentCode: req.AgentCode,
DisplayName: req.DisplayName,
Avatar: strings.TrimSpace(req.Avatar),
ServiceStatus: req.ServiceStatus,
MaxConcurrentCount: req.MaxConcurrentCount,
PriorityLevel: req.PriorityLevel,
AutoAssignEnabled: req.AutoAssignEnabled,
ReceiveOfflineMessage: req.ReceiveOfflineMessage,
Remark: strings.TrimSpace(req.Remark),
}, nil
}
func (s *agentProfileService) dispatchPendingConversationsIfEligible(item *models.AgentProfile) {
if item == nil {
return
}
if item.Status != enums.StatusOk {
return
}
if !item.AutoAssignEnabled || item.MaxConcurrentCount <= 0 {
return
}
if item.ServiceStatus != enums.ServiceStatusIdle {
return
}
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
}

View File

@@ -0,0 +1,225 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRevisionService = newAgentRevisionService()
func newAgentRevisionService() *agentRevisionService {
return &agentRevisionService{}
}
type agentRevisionService struct{}
func (s *agentRevisionService) Get(id int64) *models.AgentRevision {
if id <= 0 {
return nil
}
return repositories.AgentRevisionRepository.Get(sqls.DB(), id)
}
func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevision {
return repositories.AgentRevisionRepository.FindByAgentID(sqls.DB(), agentID)
}
type agentRevisionDefinition struct {
Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"`
WorkflowBindings []AgentRevisionWorkflowBinding `json:"workflowBindings"`
}
type AgentRevisionWorkflowBinding struct {
WorkflowID int64 `json:"workflowId"`
WorkflowVersionID int64 `json:"workflowVersionId"`
ToolName string `json:"toolName"`
TriggerInstruction string `json:"triggerInstruction"`
Priority int `json:"priority"`
}
// agentRevisionModel deliberately excludes APIKey. A revision must capture
// reproducible routing/model parameters without duplicating credentials.
type agentRevisionModel struct {
ConfigID int64 `json:"configId"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
ModelType string `json:"modelType"`
ModelName string `json:"modelName"`
MaxContextTokens int `json:"maxContextTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
TimeoutMS int `json:"timeoutMs"`
MaxRetryCount int `json:"maxRetryCount"`
}
type agentRevisionAgent struct {
Name string `json:"name"`
Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"`
MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"`
KnowledgePolicy string `json:"knowledgePolicy"`
ServiceMode int `json:"serviceMode"`
SystemPrompt string `json:"systemPrompt"`
WelcomeMessage string `json:"welcomeMessage"`
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
TeamIDs string `json:"teamIds"`
HandoffMode int `json:"handoffMode"`
FallbackMode int `json:"fallbackMode"`
FallbackMessage string `json:"fallbackMessage"`
KnowledgeIDs string `json:"knowledgeIds"`
SkillIDs string `json:"skillIds"`
AllowedMCPTools string `json:"allowedMcpTools"`
AutoHandoffOnFailure *bool `json:"autoHandoffOnFailure,omitempty"`
HandoffMessage string `json:"handoffMessage,omitempty"`
OffHoursMessage string `json:"offHoursMessage,omitempty"`
}
// AgentRevisionSnapshot is the immutable runtime configuration restored from
// a published revision. Model credentials deliberately remain on the current
// AIConfig so credential rotation does not require republishing every Agent.
type AgentRevisionSnapshot struct {
Revision models.AgentRevision
Agent models.AIAgent
AIConfig models.AIConfig
WorkflowBindings []AgentRevisionWorkflowBinding
}
// ResolvePublishedSnapshot restores an immutable published Agent revision.
func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) {
if agent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("Agent is not published")
}
revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID)
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("published Agent revision does not exist")
}
snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config}
if strings.TrimSpace(revision.Definition) == "" {
return nil, errorsx.InvalidParam("published Agent revision definition is empty")
}
definition := agentRevisionDefinition{}
if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil {
return nil, errorsx.InvalidParam("published Agent revision is invalid")
}
publishedConfigID := definition.Agent.AIConfigID
if publishedConfigID <= 0 {
publishedConfigID = definition.Model.ConfigID
}
if publishedConfigID > 0 && publishedConfigID != config.ID {
publishedConfig := repositories.AIConfigRepository.Get(sqls.DB(), publishedConfigID)
if publishedConfig == nil || publishedConfig.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("published agent model config is unavailable")
}
snapshot.AIConfig = *publishedConfig
}
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
return snapshot, nil
}
func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionAgent) {
if agent == nil {
return
}
agent.Name = definition.Name
agent.Description = definition.Description
agent.AIConfigID = definition.AIConfigID
agent.MaxSteps = definition.MaxSteps
agent.ContextWindow = definition.ContextWindow
agent.ToolPolicy = definition.ToolPolicy
agent.KnowledgePolicy = definition.KnowledgePolicy
agent.ServiceMode = enums.IMConversationServiceMode(definition.ServiceMode)
agent.SystemPrompt = definition.SystemPrompt
agent.WelcomeMessage = definition.WelcomeMessage
agent.ReplyTimeoutSeconds = definition.ReplyTimeoutSeconds
agent.TeamIDs = definition.TeamIDs
agent.HandoffMode = enums.AIAgentHandoffMode(definition.HandoffMode)
agent.FallbackMode = enums.AIAgentFallbackMode(definition.FallbackMode)
agent.FallbackMessage = definition.FallbackMessage
agent.KnowledgeIDs = definition.KnowledgeIDs
agent.SkillIDs = definition.SkillIDs
agent.AllowedMCPTools = definition.AllowedMCPTools
if definition.AutoHandoffOnFailure != nil {
agent.AutoHandoffOnFailure = *definition.AutoHandoffOnFailure
} else {
agent.AutoHandoffOnFailure = true
}
agent.HandoffMessage = definition.HandoffMessage
agent.OffHoursMessage = definition.OffHoursMessage
}
func applyRevisionModelSnapshot(config *models.AIConfig, definition agentRevisionModel) {
if config == nil || definition.ConfigID <= 0 {
return
}
config.Provider = enums.AIProvider(definition.Provider)
config.BaseURL = definition.BaseURL
config.ModelType = enums.AIModelType(definition.ModelType)
config.ModelName = definition.ModelName
config.MaxContextTokens = definition.MaxContextTokens
config.MaxOutputTokens = definition.MaxOutputTokens
config.TimeoutMS = definition.TimeoutMS
config.MaxRetryCount = definition.MaxRetryCount
}
func (s *agentRevisionService) PublishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
return s.publishSnapshot(db, agent, operator)
}
func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
model := agentRevisionModel{ConfigID: agent.AIConfigID}
if config := repositories.AIConfigRepository.Get(db, agent.AIConfigID); config != nil {
model = agentRevisionModel{
ConfigID: config.ID, Provider: string(config.Provider), BaseURL: config.BaseURL, ModelType: string(config.ModelType),
ModelName: config.ModelName, MaxContextTokens: config.MaxContextTokens, MaxOutputTokens: config.MaxOutputTokens,
TimeoutMS: config.TimeoutMS, MaxRetryCount: config.MaxRetryCount,
}
}
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID,
MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow,
ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt,
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs,
SkillIDs: agent.SkillIDs, AllowedMCPTools: agent.AllowedMCPTools,
AutoHandoffOnFailure: &agent.AutoHandoffOnFailure, HandoffMessage: agent.HandoffMessage,
OffHoursMessage: agent.OffHoursMessage,
},
Model: model,
}
for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) {
definition.WorkflowBindings = append(definition.WorkflowBindings, AgentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority})
}
data, err := json.Marshal(definition)
if err != nil {
return nil, err
}
now := time.Now()
hash := sha256.Sum256(data)
item := &models.AgentRevision{
AgentID: agent.ID, Revision: repositories.AgentRevisionRepository.MaxRevisionByAgentID(db, agent.ID) + 1,
Status: enums.StatusOk, Definition: string(data), DefinitionHash: hex.EncodeToString(hash[:]),
PublishedAt: &now, PublishedByID: operator.UserID, PublishedByName: operator.Username, AuditFields: utils.BuildAuditFields(operator),
}
if err := repositories.AgentRevisionRepository.Create(db, item); err != nil {
return nil, err
}
return item, nil
}

View File

@@ -0,0 +1,113 @@
package services
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIConfig{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
autoHandoffFalse := false
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
Name: "published agent", AIConfigID: 8,
MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90,
AutoHandoffOnFailure: &autoHandoffFalse,
HandoffMessage: "Custom notice",
OffHoursMessage: "Custom off hours notice",
},
Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000},
}
data, err := json.Marshal(definition)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction"}, models.AIConfig{ID: 8, APIKey: "rotated-secret", ModelName: "draft-model"})
if err != nil {
t.Fatalf("ResolvePublishedSnapshot: %v", err)
}
if snapshot.Agent.SystemPrompt != "published instruction" || snapshot.Agent.MaxSteps != 5 || snapshot.Agent.ReplyTimeoutSeconds != 90 {
t.Fatalf("agent snapshot not restored: %#v", snapshot.Agent)
}
if snapshot.Agent.AutoHandoffOnFailure != false || snapshot.Agent.HandoffMessage != "Custom notice" || snapshot.Agent.OffHoursMessage != "Custom off hours notice" {
t.Fatalf("auto handoff, notice or off-hours notice not restored: %#v", snapshot.Agent)
}
if snapshot.AIConfig.ModelName != "published-model" || snapshot.AIConfig.BaseURL != "https://published.example/v1" || snapshot.AIConfig.APIKey != "rotated-secret" {
t.Fatalf("model snapshot not restored safely: %#v", snapshot.AIConfig)
}
}
func TestAgentRevisionServiceUsesPublishedModelConfigAfterDraftConfigChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIConfig{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
publishedConfig := &models.AIConfig{
Name: "published", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI,
ModelType: enums.AIModelTypeLLM, ModelName: "current-published-model", APIKey: "published-secret",
}
draftConfig := &models.AIConfig{
Name: "draft", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI,
ModelType: enums.AIModelTypeLLM, ModelName: "draft-model", APIKey: "draft-secret",
}
if err := db.Create(publishedConfig).Error; err != nil {
t.Fatalf("create published config: %v", err)
}
if err := db.Create(draftConfig).Error; err != nil {
t.Fatalf("create draft config: %v", err)
}
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{AIConfigID: publishedConfig.ID, SystemPrompt: "published instruction"},
Model: agentRevisionModel{
ConfigID: publishedConfig.ID, Provider: string(enums.AIProviderOpenAI),
ModelType: string(enums.AIModelTypeLLM), ModelName: "snapshotted-published-model",
},
}
data, err := json.Marshal(definition)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(
models.AIAgent{ID: 7, AIConfigID: draftConfig.ID, PublishedRevisionID: revision.ID},
*draftConfig,
)
if err != nil {
t.Fatalf("ResolvePublishedSnapshot: %v", err)
}
if snapshot.AIConfig.ID != publishedConfig.ID {
t.Fatalf("model config id = %d, want published config %d", snapshot.AIConfig.ID, publishedConfig.ID)
}
if snapshot.AIConfig.ModelName != "snapshotted-published-model" || snapshot.AIConfig.APIKey != "published-secret" {
t.Fatalf("published model snapshot not restored safely: %#v", snapshot.AIConfig)
}
}

View File

@@ -0,0 +1,389 @@
package services
import (
"regexp"
"slices"
"sort"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRunService = newAgentRunService()
func newAgentRunService() *agentRunService {
return &agentRunService{}
}
type agentRunService struct{}
type AgentRunMetrics struct {
TotalRuns int `json:"totalRuns"`
CompletedRuns int `json:"completedRuns"`
FailedRuns int `json:"failedRuns"`
InterruptedRuns int `json:"interruptedRuns"`
CompletionRate float64 `json:"completionRate"`
ToolCalls int `json:"toolCalls"`
ToolSuccessRate float64 `json:"toolSuccessRate"`
AverageSteps float64 `json:"averageSteps"`
AverageDurationMS int64 `json:"averageDurationMs"`
P95DurationMS int64 `json:"p95DurationMs"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
HandoffRate float64 `json:"handoffRate"`
KnowledgeFallbackRate float64 `json:"knowledgeFallbackRate"`
ResumedInterrupts int `json:"resumedInterrupts"`
ResolvedInterrupts int `json:"resolvedInterrupts"`
InterruptRecoveryRate float64 `json:"interruptRecoveryRate"`
ReviewedRuns int `json:"reviewedRuns"`
ResolvedRuns int `json:"resolvedRuns"`
ResolutionRate float64 `json:"resolutionRate"`
UnsupportedEvidenceRuns int `json:"unsupportedEvidenceRuns"`
UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"`
}
const maxAgentAuditPreviewChars = 4000
var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
func (s *agentRunService) Get(id int64) *models.AgentRun {
if id <= 0 {
return nil
}
return repositories.AgentRunRepository.Get(sqls.DB(), id)
}
func (s *agentRunService) FindPageByParams(queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) {
return repositories.AgentRunRepository.FindPageByParams(sqls.DB(), queryParams)
}
func (s *agentRunService) GetDetail(id int64) (*models.AgentRun, []models.AgentStep, []models.AgentToolCall) {
run := s.Get(id)
if run == nil {
return nil, nil, nil
}
return run,
repositories.AgentStepRepository.FindByAgentRunID(sqls.DB(), id),
repositories.AgentToolCallRepository.FindByAgentRunID(sqls.DB(), id)
}
func (s *agentRunService) GetLatestStepID(agentRunID int64) int64 {
step := repositories.AgentStepRepository.LastByAgentRunID(sqls.DB(), agentRunID)
if step == nil {
return 0
}
return step.ID
}
func (s *agentRunService) GetQualityFeedback(agentRunID int64) *models.AgentRunQualityFeedback {
return repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(sqls.DB(), agentRunID)
}
func (s *agentRunService) SaveQualityFeedback(req request.SaveAgentRunQualityFeedbackRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if req.AgentRunID <= 0 {
return errorsx.InvalidParam("agent run id is required")
}
if !slices.Contains(enums.AgentRunResolutionStatusValues, req.ResolutionStatus) || !slices.Contains(enums.AgentRunEvidenceStatusValues, req.EvidenceStatus) {
return errorsx.InvalidParam("invalid agent run quality feedback status")
}
comment := strings.TrimSpace(req.Comment)
if len([]rune(comment)) > 2000 {
return errorsx.InvalidParam("agent run quality feedback comment is too long")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if repositories.AgentRunRepository.Get(ctx.Tx, req.AgentRunID) == nil {
return errorsx.InvalidParam("agent run does not exist")
}
current := repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(ctx.Tx, req.AgentRunID)
if current == nil {
return repositories.AgentRunQualityFeedbackRepository.Create(ctx.Tx, &models.AgentRunQualityFeedback{
AgentRunID: req.AgentRunID, ResolutionStatus: req.ResolutionStatus, EvidenceStatus: req.EvidenceStatus, Comment: comment,
AuditFields: utils.BuildAuditFields(operator),
})
}
return repositories.AgentRunQualityFeedbackRepository.Updates(ctx.Tx, current.ID, map[string]any{
"resolution_status": req.ResolutionStatus,
"evidence_status": req.EvidenceStatus,
"comment": comment,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
}
// GetMetrics aggregates normalized audit records in Go so SQLite and MySQL
// use identical percentile and rate semantics.
func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics {
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
metrics := s.aggregateMetrics(sqls.DB(), runs)
if len(runs) == 0 {
return metrics
}
conversationCount := repositories.ConversationRepository.CountByAIAgentID(sqls.DB(), aiAgentID)
if conversationCount > 0 {
metrics.HandoffRate = float64(repositories.ConversationRepository.CountHandoffByAIAgentID(sqls.DB(), aiAgentID)) / float64(conversationCount)
}
return metrics
}
func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics {
metrics := AgentRunMetrics{TotalRuns: len(runs)}
if len(runs) == 0 {
return metrics
}
runIDs := make([]int64, 0, len(runs))
durations := make([]int64, 0, len(runs))
var durationTotal int64
for _, run := range runs {
runIDs = append(runIDs, run.ID)
switch run.Status {
case "completed":
metrics.CompletedRuns++
case "failed":
metrics.FailedRuns++
case "interrupted":
metrics.InterruptedRuns++
}
metrics.PromptTokens += int64(run.PromptTokens)
metrics.CompletionTokens += int64(run.CompletionTokens)
if run.EndedAt != nil {
duration := run.EndedAt.Sub(run.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
durations = append(durations, duration)
durationTotal += duration
}
}
metrics.CompletionRate = float64(metrics.CompletedRuns) / float64(metrics.TotalRuns)
if len(durations) > 0 {
metrics.AverageDurationMS = durationTotal / int64(len(durations))
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
index := (len(durations)*95+99)/100 - 1
metrics.P95DurationMS = durations[index]
}
steps := repositories.AgentStepRepository.FindByAgentRunIDs(db, runIDs)
metrics.AverageSteps = float64(len(steps)) / float64(metrics.TotalRuns)
fallbackRunIDs := make(map[int64]struct{})
for _, step := range steps {
if step.StepType == "policy" && step.StepCode == "knowledge_evidence" {
fallbackRunIDs[step.AgentRunID] = struct{}{}
}
}
metrics.KnowledgeFallbackRate = float64(len(fallbackRunIDs)) / float64(metrics.TotalRuns)
toolCalls := repositories.AgentToolCallRepository.FindByAgentRunIDs(db, runIDs)
metrics.ToolCalls = len(toolCalls)
if len(toolCalls) > 0 {
completed := 0
for _, call := range toolCalls {
if call.Status == "completed" {
completed++
}
}
metrics.ToolSuccessRate = float64(completed) / float64(len(toolCalls))
}
interrupts := repositories.ConversationInterruptRepository.FindByAgentRunIDs(db, runIDs)
for _, interrupt := range interrupts {
if interrupt.ResumeCount <= 0 {
continue
}
metrics.ResumedInterrupts++
if interrupt.Status == "resolved" {
metrics.ResolvedInterrupts++
}
}
if metrics.ResumedInterrupts > 0 {
metrics.InterruptRecoveryRate = float64(metrics.ResolvedInterrupts) / float64(metrics.ResumedInterrupts)
}
feedbacks := repositories.AgentRunQualityFeedbackRepository.FindByAgentRunIDs(db, runIDs)
metrics.ReviewedRuns = len(feedbacks)
for _, feedback := range feedbacks {
if feedback.ResolutionStatus == enums.AgentRunResolutionStatusResolved {
metrics.ResolvedRuns++
}
if feedback.EvidenceStatus == enums.AgentRunEvidenceStatusUnsupported {
metrics.UnsupportedEvidenceRuns++
}
}
if metrics.ReviewedRuns > 0 {
metrics.ResolutionRate = float64(metrics.ResolvedRuns) / float64(metrics.ReviewedRuns)
metrics.UnsupportedEvidenceRate = float64(metrics.UnsupportedEvidenceRuns) / float64(metrics.ReviewedRuns)
}
return metrics
}
type AgentLoopRunInput struct {
ConversationID int64
AIAgentID int64
AgentRevisionID int64
SourceMessageID int64
WorkflowRunID int64
Status string
PromptTokens int
CompletionTokens int
StartedAt time.Time
EndedAt *time.Time
ErrorMessage string
TraceData string
StepType string
StepCode string
StepInputPreview string
StepOutputPreview string
AdditionalSteps []AgentLoopStepInput
ToolCalls []AgentLoopToolCallInput
}
type AgentLoopStepInput struct {
StepType string
StepCode string
WorkflowRunID int64
Status string
InputPreview string
OutputPreview string
ErrorMessage string
}
type AgentLoopToolCallInput struct {
ToolCode string
RiskLevel string
RequireConfirm bool
Status string
ArgumentsPreview string
ResultPreview string
ErrorMessage string
DurationMS int
}
// RecordResume closes or re-interrupts the original Agent Loop parent run,
// appends a normalized resume step, and records an optional resumed tool call.
func (s *agentRunService) RecordResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string, toolCall *AgentLoopToolCallInput) error {
if agentRunID <= 0 {
return nil
}
run := repositories.AgentRunRepository.Get(db, agentRunID)
if run == nil {
return nil
}
status = firstNonEmptyString(status, "completed")
now := time.Now()
if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"status": status, "ended_at": &now, "error_message": "", "updated_at": now,
}); err != nil {
return err
}
step := &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: workflowRunID,
StepType: "resume", StepCode: "confirmation_resume", Status: status,
InputPreview: "customer confirmation", OutputPreview: sanitizeAgentAuditPreview(replyText),
StartedAt: now, EndedAt: &now, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return err
}
if toolCall == nil {
return nil
}
return repositories.AgentToolCallRepository.Create(db, &models.AgentToolCall{
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(toolCall.ToolCode),
RiskLevel: strings.TrimSpace(toolCall.RiskLevel), RequireConfirm: toolCall.RequireConfirm,
Status: firstNonEmptyString(toolCall.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(toolCall.ArgumentsPreview),
ResultPreview: sanitizeAgentAuditPreview(toolCall.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(toolCall.ErrorMessage),
DurationMS: toolCall.DurationMS, CreatedAt: now,
})
}
// RecordAgentLoopRun writes the Agent Loop parent audit run and its normalized
// root step in one transaction owned by the caller.
func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInput) (int64, error) {
now := time.Now()
startedAt := input.StartedAt
if startedAt.IsZero() {
startedAt = now
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "completed"
}
run := &models.AgentRun{
ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID,
SourceMessageID: input.SourceMessageID, WorkflowRunID: input.WorkflowRunID, Status: status,
PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
}
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
return 0, err
}
durationMS := 0
if input.EndedAt != nil {
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
}
step := &models.AgentStep{
AgentRunID: run.ID, StepType: strings.TrimSpace(input.StepType), StepCode: strings.TrimSpace(input.StepCode), Status: status,
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview), OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview), ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return 0, err
}
for _, extra := range input.AdditionalSteps {
extraStep := &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: extra.WorkflowRunID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode),
Status: firstNonEmptyString(extra.Status, status), InputPreview: sanitizeAgentAuditPreview(extra.InputPreview), OutputPreview: sanitizeAgentAuditPreview(extra.OutputPreview),
ErrorMessage: sanitizeAgentAuditPreview(extra.ErrorMessage), StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, extraStep); err != nil {
return 0, err
}
}
for _, call := range input.ToolCalls {
toolCall := &models.AgentToolCall{
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(call.ToolCode), RiskLevel: strings.TrimSpace(call.RiskLevel),
RequireConfirm: call.RequireConfirm, Status: firstNonEmptyString(call.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(call.ArgumentsPreview),
ResultPreview: sanitizeAgentAuditPreview(call.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(call.ErrorMessage), DurationMS: call.DurationMS, CreatedAt: now,
}
if err := repositories.AgentToolCallRepository.Create(db, toolCall); err != nil {
return 0, err
}
}
return run.ID, nil
}
func sanitizeAgentAuditPreview(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
value = agentAuditSecretPattern.ReplaceAllString(value, "$1$2***")
runes := []rune(value)
if len(runes) <= maxAgentAuditPreviewChars {
return value
}
return strings.TrimSpace(string(runes[:maxAgentAuditPreviewChars])) + "\n[preview truncated]"
}
func firstNonEmptyString(items ...string) string {
for _, item := range items {
if value := strings.TrimSpace(item); value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,212 @@
package services
import (
"strings"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx/params"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
endedAt := now.Add(time.Second)
run := &models.AgentRun{
ConversationID: 11,
AIAgentID: 12,
WorkflowRunID: 13,
Status: "completed",
StartedAt: now,
EndedAt: &endedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err)
}
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
t.Fatalf("create agent step: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil {
t.Fatalf("create tool call: %v", err)
}
cnd := sqls.NewCnd().Eq("conversation_id", run.ConversationID).Desc("id").Page(1, 20)
queryParams := &params.QueryParams{Cnd: *cnd}
list, paging := AgentRunService.FindPageByParams(queryParams)
if len(list) != 1 || paging.Total != 1 || list[0].ID != run.ID {
t.Fatalf("unexpected agent run page: list=%#v paging=%#v", list, paging)
}
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
if item == nil || len(steps) != 1 || len(toolCalls) != 1 {
t.Fatalf("unexpected agent run detail: run=%#v steps=%#v toolCalls=%#v", item, steps, toolCalls)
}
}
func TestAgentRunServiceRecordsAgentLoopToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
runID, err := AgentRunService.RecordAgentLoopRun(db, AgentLoopRunInput{
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, Status: "completed", StartedAt: now,
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []AgentLoopToolCallInput{{
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
}},
})
if err != nil {
t.Fatalf("RecordAgentLoopRun returned error: %v", err)
}
_, steps, toolCalls := AgentRunService.GetDetail(runID)
if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 {
t.Fatalf("unexpected tool audit: %#v", toolCalls)
}
if strings.Contains(toolCalls[0].ArgumentsPreview, "abc123") || len(steps) != 1 || strings.Contains(steps[0].InputPreview, "Bearer-secret") {
t.Fatalf("sensitive audit data leaked: steps=%#v calls=%#v", steps, toolCalls)
}
}
func TestAgentRunServiceRecordsResumedToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
run := &models.AgentRun{Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create interrupted run: %v", err)
}
err := AgentRunService.RecordResume(db, run.ID, 0, "completed", "操作已执行", &AgentLoopToolCallInput{
ToolCode: "crm/update_customer", RiskLevel: "write", RequireConfirm: true,
Status: "completed", ArgumentsPreview: `{"name":"Ada"}`, ResultPreview: "updated",
})
if err != nil {
t.Fatalf("RecordResume returned error: %v", err)
}
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepType != "resume" {
t.Fatalf("unexpected resumed run audit: item=%#v steps=%#v", item, steps)
}
if len(toolCalls) != 1 || toolCalls[0].AgentStepID != steps[0].ID || !toolCalls[0].RequireConfirm || toolCalls[0].Status != "completed" {
t.Fatalf("unexpected resumed tool audit: %#v", toolCalls)
}
}
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
run := &models.AgentRun{AIAgentID: 4, Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err)
}
operator := &dto.AuthPrincipal{UserID: 7, Username: "reviewer"}
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported, Comment: "issue resolved",
}, operator); err != nil {
t.Fatalf("save quality feedback: %v", err)
}
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported, Comment: "missing evidence",
}, operator); err != nil {
t.Fatalf("update quality feedback: %v", err)
}
feedback := AgentRunService.GetQualityFeedback(run.ID)
if feedback == nil || feedback.ResolutionStatus != enums.AgentRunResolutionStatusUnresolved || feedback.EvidenceStatus != enums.AgentRunEvidenceStatusUnsupported || feedback.Comment != "missing evidence" || feedback.UpdateUserName != "reviewer" {
t.Fatalf("unexpected quality feedback: %#v", feedback)
}
}
func TestAgentRunServiceAggregatesMetrics(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
base := time.Now().Add(-time.Minute)
runs := []models.AgentRun{
{AIAgentID: 8, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 8, Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 9, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
}
for index := range runs {
if err := db.Create(&runs[index]).Error; err != nil {
t.Fatalf("create run: %v", err)
}
}
if err := db.Create(&models.AgentStep{AgentRunID: runs[0].ID, Status: "completed", StartedAt: base, CreatedAt: base}).Error; err != nil {
t.Fatalf("create step: %v", err)
}
if err := db.Create(&models.AgentStep{AgentRunID: runs[1].ID, Status: "failed", StartedAt: base, CreatedAt: base}).Error; err != nil {
t.Fatalf("create step: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[0].ID, Status: "completed", CreatedAt: base}).Error; err != nil {
t.Fatalf("create completed tool call: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[1].ID, Status: "failed", CreatedAt: base}).Error; err != nil {
t.Fatalf("create failed tool call: %v", err)
}
if err := db.Create(&models.Conversation{AIAgentID: 8}).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
handoffAt := base
if err := db.Create(&models.Conversation{AIAgentID: 8, HandoffAt: &handoffAt}).Error; err != nil {
t.Fatalf("create handoff conversation: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[0].ID, CheckPointID: "metrics-resolved", Status: "resolved", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
t.Fatalf("create resolved interrupt: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[1].ID, CheckPointID: "metrics-cancelled", Status: "cancelled", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
t.Fatalf("create cancelled interrupt: %v", err)
}
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[0].ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported}).Error; err != nil {
t.Fatalf("create resolved feedback: %v", err)
}
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[1].ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported}).Error; err != nil {
t.Fatalf("create unresolved feedback: %v", err)
}
metrics := AgentRunService.GetMetrics(8)
if metrics.TotalRuns != 2 || metrics.CompletedRuns != 1 || metrics.FailedRuns != 1 || metrics.CompletionRate != 0.5 {
t.Fatalf("unexpected run metrics: %#v", metrics)
}
if metrics.AverageDurationMS != 200 || metrics.P95DurationMS != 300 || metrics.ToolCalls != 2 || metrics.ToolSuccessRate != 0.5 || metrics.AverageSteps != 1 {
t.Fatalf("unexpected aggregate metrics: %#v", metrics)
}
if metrics.PromptTokens != 18 || metrics.CompletionTokens != 7 {
t.Fatalf("unexpected token metrics: %#v", metrics)
}
if metrics.HandoffRate != 0.5 || metrics.KnowledgeFallbackRate != 0 {
t.Fatalf("unexpected business metrics: %#v", metrics)
}
if metrics.ResumedInterrupts != 2 || metrics.ResolvedInterrupts != 1 || metrics.InterruptRecoveryRate != 0.5 {
t.Fatalf("unexpected interrupt recovery metrics: %#v", metrics)
}
if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 {
t.Fatalf("unexpected quality metrics: %#v", metrics)
}
}
func timePtr(value time.Time) *time.Time { return &value }
func setupAgentRunServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.AgentRunQualityFeedback{}, &models.Conversation{}, &models.ConversationInterrupt{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
return db
}

View File

@@ -0,0 +1,561 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"slices"
"strings"
"sync"
"time"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentTeamScheduleService = newAgentTeamScheduleService()
func newAgentTeamScheduleService() *agentTeamScheduleService {
return &agentTeamScheduleService{}
}
type agentTeamScheduleService struct {
writeMu sync.Mutex
}
const maxAgentTeamScheduleBatchItems = 500
type AgentTeamScheduleBatchPreviewResult struct {
Total int
Conflict bool
Items []AgentTeamScheduleBatchPreviewItem
}
type AgentTeamScheduleBatchPreviewItem struct {
TeamID int64
TeamName string
Date time.Time
Weekday int
StartAt time.Time
EndAt time.Time
Remark string
Conflict bool
ConflictReason string
}
type AgentTeamScheduleBatchGenerateResult struct {
Created int
}
type batchScheduleCandidate struct {
TeamID int64
TeamName string
Date time.Time
StartAt time.Time
EndAt time.Time
Remark string
}
func (s *agentTeamScheduleService) Get(id int64) *models.AgentTeamSchedule {
return repositories.AgentTeamScheduleRepository.Get(sqls.DB(), id)
}
func (s *agentTeamScheduleService) Take(where ...interface{}) *models.AgentTeamSchedule {
return repositories.AgentTeamScheduleRepository.Take(sqls.DB(), where...)
}
func (s *agentTeamScheduleService) Find(cnd *sqls.Cnd) []models.AgentTeamSchedule {
return repositories.AgentTeamScheduleRepository.Find(sqls.DB(), cnd)
}
func (s *agentTeamScheduleService) FindOne(cnd *sqls.Cnd) *models.AgentTeamSchedule {
return repositories.AgentTeamScheduleRepository.FindOne(sqls.DB(), cnd)
}
func (s *agentTeamScheduleService) FindPageByParams(params *params.QueryParams) (list []models.AgentTeamSchedule, paging *sqls.Paging) {
return repositories.AgentTeamScheduleRepository.FindPageByParams(sqls.DB(), params)
}
func (s *agentTeamScheduleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentTeamSchedule, paging *sqls.Paging) {
return repositories.AgentTeamScheduleRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *agentTeamScheduleService) Count(cnd *sqls.Cnd) int64 {
return repositories.AgentTeamScheduleRepository.Count(sqls.DB(), cnd)
}
func (s *agentTeamScheduleService) FindCalendarSchedules(req request.AgentTeamScheduleCalendarRequest) ([]models.AgentTeamSchedule, error) {
startAtValue, err := parseRequiredDateTime(req.StartAt, "error.agentTeamSchedule.startAtInvalid", "error.agentTeamSchedule.startAtInvalidWithFormat")
if err != nil {
return nil, err
}
endAtValue, err := parseRequiredDateTime(req.EndAt, "error.agentTeamSchedule.endAtInvalid", "error.agentTeamSchedule.endAtInvalidWithFormat")
if err != nil {
return nil, err
}
if !endAtValue.After(startAtValue) {
return nil, errorsx.InvalidParamI18n("error.e0296")
}
return repositories.AgentTeamScheduleRepository.FindByTimeRange(sqls.DB(), startAtValue, endAtValue, req.TeamID), nil
}
func (s *agentTeamScheduleService) Create(t *models.AgentTeamSchedule) error {
return repositories.AgentTeamScheduleRepository.Create(sqls.DB(), t)
}
func (s *agentTeamScheduleService) Update(t *models.AgentTeamSchedule) error {
return repositories.AgentTeamScheduleRepository.Update(sqls.DB(), t)
}
func (s *agentTeamScheduleService) Updates(id int64, columns map[string]interface{}) error {
return repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), id, columns)
}
func (s *agentTeamScheduleService) UpdateColumn(id int64, name string, value interface{}) error {
return repositories.AgentTeamScheduleRepository.UpdateColumn(sqls.DB(), id, name, value)
}
func (s *agentTeamScheduleService) Delete(id int64) {
repositories.AgentTeamScheduleRepository.Delete(sqls.DB(), id)
}
func (s *agentTeamScheduleService) CreateAgentTeamSchedule(req request.CreateAgentTeamScheduleRequest, operator *dto.AuthPrincipal) (*models.AgentTeamSchedule, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
s.writeMu.Lock()
item, err := s.buildScheduleModel(0, req.TeamID, req.StartAt, req.EndAt, req.Remark)
if err != nil {
s.writeMu.Unlock()
return nil, err
}
item.AuditFields = utils.BuildAuditFields(operator)
if err := repositories.AgentTeamScheduleRepository.Create(sqls.DB(), item); err != nil {
s.writeMu.Unlock()
return nil, err
}
s.writeMu.Unlock()
s.dispatchPendingConversationsIfActive(item)
return item, nil
}
func (s *agentTeamScheduleService) UpdateAgentTeamSchedule(req request.UpdateAgentTeamScheduleRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
s.writeMu.Lock()
if s.Get(req.ID) == nil {
s.writeMu.Unlock()
return errorsx.InvalidParamI18n("error.e0172")
}
item, err := s.buildScheduleModel(req.ID, req.TeamID, req.StartAt, req.EndAt, req.Remark)
if err != nil {
s.writeMu.Unlock()
return err
}
if err := repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), req.ID, map[string]any{
"team_id": item.TeamID,
"start_at": item.StartAt,
"end_at": item.EndAt,
"remark": item.Remark,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}); err != nil {
s.writeMu.Unlock()
return err
}
s.writeMu.Unlock()
s.dispatchPendingConversationsIfActive(item)
return nil
}
func (s *agentTeamScheduleService) DeleteAgentTeamSchedule(id int64) error {
if s.Get(id) == nil {
return errorsx.InvalidParamI18n("error.e0172")
}
repositories.AgentTeamScheduleRepository.Delete(sqls.DB(), id)
return nil
}
func (s *agentTeamScheduleService) BatchPreview(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*AgentTeamScheduleBatchPreviewResult, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
candidates, err := s.buildBatchScheduleCandidates(req)
if err != nil {
return nil, err
}
conflicts := s.findBatchConflict(candidates, req.Locale)
return buildBatchPreviewResult(candidates, conflicts), nil
}
func (s *agentTeamScheduleService) BatchGenerate(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*AgentTeamScheduleBatchGenerateResult, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
s.writeMu.Lock()
candidates, err := s.buildBatchScheduleCandidates(req)
if err != nil {
s.writeMu.Unlock()
return nil, err
}
conflicts := s.findBatchConflict(candidates, req.Locale)
for _, conflict := range conflicts {
if conflict != "" {
s.writeMu.Unlock()
return nil, errorsx.InvalidParamI18n("error.e0151")
}
}
schedules := make([]models.AgentTeamSchedule, 0, len(candidates))
for _, candidate := range candidates {
schedules = append(schedules, models.AgentTeamSchedule{
TeamID: candidate.TeamID,
StartAt: candidate.StartAt,
EndAt: candidate.EndAt,
Remark: candidate.Remark,
Status: enums.StatusOk,
AuditFields: utils.BuildAuditFields(operator),
})
}
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
conflicts := s.findBatchConflictByDB(ctx.Tx, candidates, req.Locale)
for _, conflict := range conflicts {
if conflict != "" {
return errorsx.InvalidParamI18n("error.e0151")
}
}
return repositories.AgentTeamScheduleRepository.CreateBatch(ctx.Tx, schedules)
}); err != nil {
s.writeMu.Unlock()
return nil, err
}
s.writeMu.Unlock()
for i := range schedules {
s.dispatchPendingConversationsIfActive(&schedules[i])
}
return &AgentTeamScheduleBatchGenerateResult{Created: len(schedules)}, nil
}
func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, endAt, remark string) (*models.AgentTeamSchedule, error) {
if teamID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0326")
}
team := AgentTeamService.Get(teamID)
if team == nil {
return nil, errorsx.InvalidParamI18n("error.e0169")
}
if !slices.Contains(enums.StatusValues, team.Status) {
return nil, errorsx.InvalidParamI18n("error.e0174")
}
startAtValue, err := parseRequiredDateTime(startAt, "error.agentTeamSchedule.startAtInvalid", "error.agentTeamSchedule.startAtInvalidWithFormat")
if err != nil {
return nil, err
}
endAtValue, err := parseRequiredDateTime(endAt, "error.agentTeamSchedule.endAtInvalid", "error.agentTeamSchedule.endAtInvalidWithFormat")
if err != nil {
return nil, err
}
if !endAtValue.After(startAtValue) {
return nil, errorsx.InvalidParamI18n("error.e0296")
}
if !sameLocalDay(startAtValue, endAtValue) {
return nil, errorsx.InvalidParamI18n("error.e0132")
}
if startAtValue.Before(startOfLocalDay(time.Now())) {
return nil, errorsx.InvalidParamI18n("error.e0085")
}
overlapping := repositories.AgentTeamScheduleRepository.FindOverlappingByTeamIDsAndTimeRange(sqls.DB(), []int64{teamID}, startAtValue, endAtValue)
for _, item := range overlapping {
if item.ID != id {
return nil, errorsx.InvalidParamI18n("error.e0309")
}
}
return &models.AgentTeamSchedule{
TeamID: teamID,
StartAt: startAtValue,
EndAt: endAtValue,
Remark: strings.TrimSpace(remark),
}, nil
}
func (s *agentTeamScheduleService) buildBatchScheduleCandidates(req request.AgentTeamScheduleBatchRequest) ([]batchScheduleCandidate, error) {
teamIDs := uniquePositiveInt64s(req.TeamIDs)
if len(teamIDs) == 0 {
return nil, errorsx.InvalidParamI18n("error.e0326")
}
weekdays, err := normalizeBatchWeekdays(req.Weekdays)
if err != nil {
return nil, err
}
startDate, err := parseRequiredDate(req.StartDate, "error.agentTeamSchedule.startDateInvalid", "error.agentTeamSchedule.startDateInvalidWithFormat")
if err != nil {
return nil, err
}
endDate, err := parseRequiredDate(req.EndDate, "error.agentTeamSchedule.endDateInvalid", "error.agentTeamSchedule.endDateInvalidWithFormat")
if err != nil {
return nil, err
}
if endDate.Before(startDate) {
return nil, errorsx.InvalidParamI18n("error.e0295")
}
if startDate.Before(startOfLocalDay(time.Now())) {
return nil, errorsx.InvalidParamI18n("error.e0085")
}
startClock, err := parseRequiredClock(req.StartTime, "error.agentTeamSchedule.startTimeInvalid", "error.agentTeamSchedule.startTimeInvalidWithFormat")
if err != nil {
return nil, err
}
endClock, err := parseRequiredClock(req.EndTime, "error.agentTeamSchedule.endTimeInvalid", "error.agentTeamSchedule.endTimeInvalidWithFormat")
if err != nil {
return nil, err
}
firstStartAt := combineDateAndClock(startDate, startClock)
firstEndAt := combineDateAndClock(startDate, endClock)
if !firstEndAt.After(firstStartAt) {
return nil, errorsx.InvalidParamI18n("error.e0296")
}
teams := AgentTeamService.FindByIds(teamIDs)
teamsByID := make(map[int64]models.AgentTeam, len(teams))
for _, team := range teams {
teamsByID[team.ID] = team
}
for _, teamID := range teamIDs {
team, ok := teamsByID[teamID]
if !ok || team.Status == enums.StatusDeleted {
return nil, errorsx.InvalidParamI18n("error.e0169")
}
if !slices.Contains(enums.StatusValues, team.Status) {
return nil, errorsx.InvalidParamI18n("error.e0174")
}
}
weekdaySet := make(map[int]struct{}, len(weekdays))
for _, weekday := range weekdays {
weekdaySet[weekday] = struct{}{}
}
candidates := make([]batchScheduleCandidate, 0)
remark := strings.TrimSpace(req.Remark)
for _, teamID := range teamIDs {
team := teamsByID[teamID]
for date := startDate; !date.After(endDate); date = date.AddDate(0, 0, 1) {
if _, ok := weekdaySet[weekdayForBatchRequest(date)]; !ok {
continue
}
if len(candidates) >= maxAgentTeamScheduleBatchItems {
return nil, errorsx.InvalidParamI18n("error.agentTeamSchedule.batchLimit", maxAgentTeamScheduleBatchItems)
}
candidates = append(candidates, batchScheduleCandidate{
TeamID: teamID,
TeamName: team.Name,
Date: date,
StartAt: combineDateAndClock(date, startClock),
EndAt: combineDateAndClock(date, endClock),
Remark: remark,
})
}
}
if len(candidates) == 0 {
return nil, errorsx.InvalidParamI18n("error.e0232")
}
return candidates, nil
}
func parseRequiredDate(value, emptyKey, formatKey string) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, errorsx.InvalidParamI18n(emptyKey)
}
ret, err := time.ParseInLocation(time.DateOnly, value, time.Local)
if err != nil {
return time.Time{}, errorsx.InvalidParamI18n(formatKey)
}
return startOfLocalDay(ret), nil
}
func parseRequiredClock(value, emptyKey, formatKey string) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, errorsx.InvalidParamI18n(emptyKey)
}
layouts := []string{"15:04", "15:04:05"}
for _, layout := range layouts {
if ret, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return ret, nil
}
}
return time.Time{}, errorsx.InvalidParamI18n(formatKey)
}
func combineDateAndClock(date, clock time.Time) time.Time {
year, month, day := date.In(time.Local).Date()
hour, minute, second := clock.In(time.Local).Clock()
return time.Date(year, month, day, hour, minute, second, 0, time.Local)
}
func buildBatchPreviewResult(candidates []batchScheduleCandidate, conflicts map[int]string) *AgentTeamScheduleBatchPreviewResult {
items := make([]AgentTeamScheduleBatchPreviewItem, 0, len(candidates))
hasConflict := false
for i, candidate := range candidates {
conflictReason := conflicts[i]
conflict := conflictReason != ""
if conflict {
hasConflict = true
}
items = append(items, AgentTeamScheduleBatchPreviewItem{
TeamID: candidate.TeamID,
TeamName: candidate.TeamName,
Date: candidate.Date,
Weekday: weekdayForBatchRequest(candidate.Date),
StartAt: candidate.StartAt,
EndAt: candidate.EndAt,
Remark: candidate.Remark,
Conflict: conflict,
ConflictReason: conflictReason,
})
}
return &AgentTeamScheduleBatchPreviewResult{
Total: len(items),
Conflict: hasConflict,
Items: items,
}
}
func (s *agentTeamScheduleService) findBatchConflict(candidates []batchScheduleCandidate, locale string) map[int]string {
return s.findBatchConflictByDB(sqls.DB(), candidates, locale)
}
func (s *agentTeamScheduleService) findBatchConflictByDB(db *gorm.DB, candidates []batchScheduleCandidate, locale string) map[int]string {
conflicts := make(map[int]string)
if len(candidates) == 0 {
return conflicts
}
teamIDs := make([]int64, 0, len(candidates))
startAt := candidates[0].StartAt
endAt := candidates[0].EndAt
for _, candidate := range candidates {
teamIDs = append(teamIDs, candidate.TeamID)
if candidate.StartAt.Before(startAt) {
startAt = candidate.StartAt
}
if candidate.EndAt.After(endAt) {
endAt = candidate.EndAt
}
}
existing := repositories.AgentTeamScheduleRepository.FindOverlappingByTeamIDsAndTimeRange(db, uniquePositiveInt64s(teamIDs), startAt, endAt)
for i, candidate := range candidates {
for _, item := range existing {
if item.TeamID != candidate.TeamID {
continue
}
if item.StartAt.Before(candidate.EndAt) && item.EndAt.After(candidate.StartAt) {
conflicts[i] = i18nx.Getf(locale, "error.agentTeamSchedule.conflictRange", item.StartAt.Format(time.DateTime), item.EndAt.Format(time.DateTime))
break
}
}
}
return conflicts
}
func normalizeBatchWeekdays(values []int) ([]int, error) {
seen := make(map[int]struct{}, len(values))
ret := make([]int, 0, len(values))
for _, value := range values {
if value < 1 || value > 7 {
return nil, errorsx.InvalidParamI18n("error.e0228")
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
ret = append(ret, value)
}
if len(ret) == 0 {
return nil, errorsx.InvalidParamI18n("error.e0329")
}
return ret, nil
}
func weekdayForBatchRequest(value time.Time) int {
if value.Weekday() == time.Sunday {
return 7
}
return int(value.Weekday())
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
ret := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
ret = append(ret, value)
}
return ret
}
func parseRequiredDateTime(value, emptyKey, formatKey string) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, errorsx.InvalidParamI18n(emptyKey)
}
ret, err := parseDateTimeValue(value)
if err != nil {
return time.Time{}, errorsx.InvalidParamI18n(formatKey)
}
return ret, nil
}
func parseDateTimeValue(value string) (time.Time, error) {
layouts := []string{
time.DateTime,
time.RFC3339,
"2006-01-02T15:04",
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
if ret, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return ret, nil
}
}
return time.Time{}, errorsx.InvalidParamI18n("error.e0227")
}
func startOfLocalDay(value time.Time) time.Time {
year, month, day := value.In(time.Local).Date()
return time.Date(year, month, day, 0, 0, 0, 0, time.Local)
}
func sameLocalDay(a, b time.Time) bool {
aYear, aMonth, aDay := a.In(time.Local).Date()
bYear, bMonth, bDay := b.In(time.Local).Date()
return aYear == bYear && aMonth == bMonth && aDay == bDay
}
func (s *agentTeamScheduleService) dispatchPendingConversationsIfActive(item *models.AgentTeamSchedule) {
if item == nil {
return
}
if item.Status != enums.StatusOk {
return
}
now := time.Now()
if item.StartAt.After(now) || !item.EndAt.After(now) {
return
}
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
}

View File

@@ -0,0 +1,591 @@
package services_test
import (
"strings"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentTeamScheduleServiceFindCalendarSchedulesReturnsIntersectingSchedules(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestData(t, db)
list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{
StartAt: "2026-04-27 00:00:00",
EndAt: "2026-05-04 00:00:00",
})
if err != nil {
t.Fatalf("FindCalendarSchedules() error = %v", err)
}
if len(list) != 3 {
t.Fatalf("expected 3 intersecting schedules, got %d: %+v", len(list), list)
}
gotIDs := make([]int64, 0, len(list))
for _, item := range list {
gotIDs = append(gotIDs, item.ID)
}
wantIDs := []int64{1, 2, 3}
for i, want := range wantIDs {
if gotIDs[i] != want {
t.Fatalf("expected ids %v, got %v", wantIDs, gotIDs)
}
}
}
func TestAgentTeamScheduleServiceFindCalendarSchedulesFiltersTeamID(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestData(t, db)
list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{
StartAt: "2026-04-27 00:00:00",
EndAt: "2026-05-04 00:00:00",
TeamID: 2,
})
if err != nil {
t.Fatalf("FindCalendarSchedules() error = %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 schedule for team 2, got %d: %+v", len(list), list)
}
if list[0].ID != 3 || list[0].TeamID != 2 {
t.Fatalf("unexpected schedule: %+v", list[0])
}
}
func TestAgentTeamScheduleServiceFindCalendarSchedulesValidatesTimeRange(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
_, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{
StartAt: "2026-05-04 00:00:00",
EndAt: "2026-04-27 00:00:00",
})
if err == nil {
t.Fatalf("expected invalid time range to fail")
}
}
func TestAgentTeamScheduleServiceCreateRejectsCrossDaySchedule(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
tomorrow := time.Now().AddDate(0, 0, 1)
_, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{
TeamID: 1,
StartAt: formatTestDateTime(tomorrow, "22:00:00"),
EndAt: formatTestDateTime(tomorrow.AddDate(0, 0, 1), "08:00:00"),
}, testOperator())
if err == nil {
t.Fatalf("expected cross-day schedule to fail")
}
if !strings.Contains(err.Error(), "不能跨天") && !strings.Contains(err.Error(), "span multiple days") {
t.Fatalf("expected cross-day error, got %v", err)
}
}
func TestAgentTeamScheduleServiceCreateRejectsHistoricalScheduleByDay(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
yesterday := time.Now().AddDate(0, 0, -1)
_, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{
TeamID: 1,
StartAt: formatTestDateTime(yesterday, "09:00:00"),
EndAt: formatTestDateTime(yesterday, "18:00:00"),
}, testOperator())
if err == nil {
t.Fatalf("expected historical schedule to fail")
}
if !strings.Contains(err.Error(), "历史日期") && !strings.Contains(err.Error(), "past date") {
t.Fatalf("expected historical date error, got %v", err)
}
}
func TestAgentTeamScheduleServiceCreateAllowsTodayEarlierThanCurrentTime(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
today := time.Now()
item, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{
TeamID: 1,
StartAt: formatTestDateTime(today, "00:00:00"),
EndAt: formatTestDateTime(today, "01:00:00"),
}, testOperator())
if err != nil {
t.Fatalf("expected today's schedule to pass, got %v", err)
}
if item == nil || item.ID == 0 {
t.Fatalf("expected created schedule, got %+v", item)
}
}
func TestAgentTeamScheduleServiceUpdateRejectsCrossDaySchedule(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, db)
existingID := createFutureAgentTeamSchedule(t, db)
tomorrow := time.Now().AddDate(0, 0, 1)
err := services.AgentTeamScheduleService.UpdateAgentTeamSchedule(request.UpdateAgentTeamScheduleRequest{
ID: existingID,
CreateAgentTeamScheduleRequest: request.CreateAgentTeamScheduleRequest{
TeamID: 1,
StartAt: formatTestDateTime(tomorrow, "22:00:00"),
EndAt: formatTestDateTime(tomorrow.AddDate(0, 0, 1), "08:00:00"),
},
}, testOperator())
if err == nil {
t.Fatalf("expected cross-day update to fail")
}
if !strings.Contains(err.Error(), "不能跨天") && !strings.Contains(err.Error(), "span multiple days") {
t.Fatalf("expected cross-day error, got %v", err)
}
}
func TestAgentTeamScheduleServiceUpdateRejectsHistoricalScheduleByDay(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, db)
existingID := createFutureAgentTeamSchedule(t, db)
yesterday := time.Now().AddDate(0, 0, -1)
err := services.AgentTeamScheduleService.UpdateAgentTeamSchedule(request.UpdateAgentTeamScheduleRequest{
ID: existingID,
CreateAgentTeamScheduleRequest: request.CreateAgentTeamScheduleRequest{
TeamID: 1,
StartAt: formatTestDateTime(yesterday, "09:00:00"),
EndAt: formatTestDateTime(yesterday, "18:00:00"),
},
}, testOperator())
if err == nil {
t.Fatalf("expected historical update to fail")
}
if !strings.Contains(err.Error(), "历史日期") && !strings.Contains(err.Error(), "past date") {
t.Fatalf("expected historical date error, got %v", err)
}
}
func TestAgentTeamScheduleServiceBatchPreviewExpandsSharedRule(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
nextMonday := nextTestWeekday(time.Monday)
nextWednesday := nextMonday.AddDate(0, 0, 2)
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1, 2},
StartDate: nextMonday.Format(time.DateOnly),
EndDate: nextMonday.AddDate(0, 0, 6).Format(time.DateOnly),
Weekdays: []int{1, 3},
StartTime: "09:00",
EndTime: "18:00",
Remark: "工作日白班",
}, testOperator())
if err != nil {
t.Fatalf("BatchPreview() error = %v", err)
}
if preview.Total != 4 || len(preview.Items) != 4 {
t.Fatalf("expected 4 preview items, got total=%d len=%d", preview.Total, len(preview.Items))
}
if preview.Conflict {
t.Fatalf("expected no conflict, got %+v", preview.Items)
}
teamNames := make(map[int64]string)
for _, item := range preview.Items {
if item.TeamName == "" {
t.Fatalf("expected all preview items to have team names: %+v", preview.Items)
}
teamNames[item.TeamID] = item.TeamName
}
if teamNames[1] == "" || teamNames[2] == "" {
t.Fatalf("expected team ids 1 and 2 with names, got %v", teamNames)
}
type previewKey struct {
teamID int64
date string
}
itemsByKey := make(map[previewKey]services.AgentTeamScheduleBatchPreviewItem)
for _, item := range preview.Items {
itemsByKey[previewKey{teamID: item.TeamID, date: item.Date.Format(time.DateOnly)}] = item
}
expected := []struct {
teamID int64
date time.Time
weekday int
}{
{teamID: 1, date: nextMonday, weekday: 1},
{teamID: 1, date: nextWednesday, weekday: 3},
{teamID: 2, date: nextMonday, weekday: 1},
{teamID: 2, date: nextWednesday, weekday: 3},
}
for _, want := range expected {
wantDate := want.date.Format(time.DateOnly)
item, ok := itemsByKey[previewKey{teamID: want.teamID, date: wantDate}]
if !ok {
t.Fatalf("expected preview item for teamID=%d date=%s, got %+v", want.teamID, wantDate, preview.Items)
}
if item.Weekday != want.weekday ||
item.StartAt.Format(time.DateTime) != formatTestDateTime(want.date, "09:00:00") ||
item.EndAt.Format(time.DateTime) != formatTestDateTime(want.date, "18:00:00") ||
item.Remark != "工作日白班" {
t.Fatalf("unexpected preview item for teamID=%d date=%s: %+v", want.teamID, wantDate, item)
}
}
}
func TestAgentTeamScheduleServiceBatchPreviewRejectsHistoricalDate(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
yesterday := time.Now().AddDate(0, 0, -1)
_, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1},
StartDate: yesterday.Format(time.DateOnly),
EndDate: yesterday.Format(time.DateOnly),
Weekdays: []int{weekdayForRequest(yesterday)},
StartTime: "09:00",
EndTime: "18:00",
}, testOperator())
if err == nil {
t.Fatalf("expected historical batch preview to fail")
}
if !strings.Contains(err.Error(), "历史日期") && !strings.Contains(err.Error(), "past date") {
t.Fatalf("expected historical date error, got %v", err)
}
}
func TestAgentTeamScheduleServiceBatchPreviewRejectsInvalidTimeRange(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
tomorrow := time.Now().AddDate(0, 0, 1)
_, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1},
StartDate: tomorrow.Format(time.DateOnly),
EndDate: tomorrow.Format(time.DateOnly),
Weekdays: []int{weekdayForRequest(tomorrow)},
StartTime: "18:00",
EndTime: "09:00",
}, testOperator())
if err == nil {
t.Fatalf("expected invalid time range to fail")
}
if !strings.Contains(err.Error(), "结束时间必须晚于开始时间") && !strings.Contains(err.Error(), "later than start time") {
t.Fatalf("expected invalid time range error, got %v", err)
}
}
func TestAgentTeamScheduleServiceBatchPreviewRejectsOverLimit(t *testing.T) {
setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, sqls.DB())
today := time.Now()
_, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1, 2},
StartDate: today.Format(time.DateOnly),
EndDate: today.AddDate(0, 0, 260).Format(time.DateOnly),
Weekdays: []int{1, 2, 3, 4, 5, 6, 7},
StartTime: "09:00",
EndTime: "18:00",
}, testOperator())
if err == nil {
t.Fatalf("expected over-limit preview to fail")
}
if !strings.Contains(err.Error(), "500") {
t.Fatalf("expected 500 limit error, got %v", err)
}
}
func TestAgentTeamScheduleServiceBatchPreviewMarksConflicts(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, db)
targetDay := time.Now().AddDate(0, 0, 2)
existing := models.AgentTeamSchedule{
TeamID: 1,
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
Status: enums.StatusOk,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("create existing schedule error = %v", err)
}
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1, 2},
StartDate: targetDay.Format(time.DateOnly),
EndDate: targetDay.Format(time.DateOnly),
Weekdays: []int{weekdayForRequest(targetDay)},
StartTime: "09:00",
EndTime: "18:00",
}, testOperator())
if err != nil {
t.Fatalf("BatchPreview() error = %v", err)
}
if !preview.Conflict {
t.Fatalf("expected preview conflict, got %+v", preview)
}
itemsByTeamID := make(map[int64]services.AgentTeamScheduleBatchPreviewItem)
for _, item := range preview.Items {
itemsByTeamID[item.TeamID] = item
}
team1Item, ok := itemsByTeamID[1]
if !ok {
t.Fatalf("expected team 1 preview item, got %+v", preview.Items)
}
team2Item, ok := itemsByTeamID[2]
if !ok {
t.Fatalf("expected team 2 preview item, got %+v", preview.Items)
}
if !team1Item.Conflict || team1Item.ConflictReason == "" {
t.Fatalf("expected team 1 preview item to be marked as conflict: %+v", team1Item)
}
if team2Item.Conflict {
t.Fatalf("expected team 2 preview item to have no conflict: %+v", team2Item)
}
}
func TestAgentTeamScheduleServiceBatchPreviewIgnoresDisabledOverlappingSchedule(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, db)
targetDay := time.Now().AddDate(0, 0, 2)
existing := models.AgentTeamSchedule{
TeamID: 1,
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
Status: enums.StatusDisabled,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("create existing schedule error = %v", err)
}
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1},
StartDate: targetDay.Format(time.DateOnly),
EndDate: targetDay.Format(time.DateOnly),
Weekdays: []int{weekdayForRequest(targetDay)},
StartTime: "09:00",
EndTime: "18:00",
}, testOperator())
if err != nil {
t.Fatalf("BatchPreview() error = %v", err)
}
if preview.Conflict {
t.Fatalf("expected disabled overlapping schedule to be ignored, got %+v", preview)
}
if len(preview.Items) != 1 || preview.Items[0].Conflict {
t.Fatalf("expected one non-conflicting preview item, got %+v", preview.Items)
}
}
func TestAgentTeamScheduleServiceBatchGenerateCreatesAllSchedules(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, db)
nextMonday := nextTestWeekday(time.Monday)
result, err := services.AgentTeamScheduleService.BatchGenerate(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1, 2},
StartDate: nextMonday.Format(time.DateOnly),
EndDate: nextMonday.AddDate(0, 0, 2).Format(time.DateOnly),
Weekdays: []int{1, 3},
StartTime: "09:00",
EndTime: "18:00",
Remark: "批量生成",
}, testOperator())
if err != nil {
t.Fatalf("BatchGenerate() error = %v", err)
}
if result.Created != 4 {
t.Fatalf("expected 4 created schedules, got %d", result.Created)
}
var schedules []models.AgentTeamSchedule
if err := db.Where("remark = ?", "批量生成").
Order("team_id ASC, start_at ASC").
Find(&schedules).Error; err != nil {
t.Fatalf("query generated schedules error = %v", err)
}
if len(schedules) != 4 {
t.Fatalf("expected 4 stored schedules, got %d: %+v", len(schedules), schedules)
}
expected := []struct {
teamID int64
startAt string
endAt string
}{
{teamID: 1, startAt: formatTestDateTime(nextMonday, "09:00:00"), endAt: formatTestDateTime(nextMonday, "18:00:00")},
{teamID: 1, startAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "09:00:00"), endAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "18:00:00")},
{teamID: 2, startAt: formatTestDateTime(nextMonday, "09:00:00"), endAt: formatTestDateTime(nextMonday, "18:00:00")},
{teamID: 2, startAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "09:00:00"), endAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "18:00:00")},
}
for i, want := range expected {
got := schedules[i]
if got.TeamID != want.teamID ||
got.StartAt.Format(time.DateTime) != want.startAt ||
got.EndAt.Format(time.DateTime) != want.endAt {
t.Fatalf("unexpected schedule at index %d: got teamID=%d startAt=%s endAt=%s, want teamID=%d startAt=%s endAt=%s",
i,
got.TeamID,
got.StartAt.Format(time.DateTime),
got.EndAt.Format(time.DateTime),
want.teamID,
want.startAt,
want.endAt,
)
}
}
}
func TestAgentTeamScheduleServiceBatchGenerateRejectsConflictsWithoutPartialCreate(t *testing.T) {
db := setupAgentTeamScheduleTestDB(t)
createAgentTeamScheduleTestTeams(t, db)
targetDay := time.Now().AddDate(0, 0, 2)
existing := models.AgentTeamSchedule{
TeamID: 1,
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
Status: enums.StatusOk,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("create existing schedule error = %v", err)
}
_, err := services.AgentTeamScheduleService.BatchGenerate(request.AgentTeamScheduleBatchRequest{
TeamIDs: []int64{1, 2},
StartDate: targetDay.Format(time.DateOnly),
EndDate: targetDay.Format(time.DateOnly),
Weekdays: []int{weekdayForRequest(targetDay)},
StartTime: "09:00",
EndTime: "18:00",
Remark: "不应创建",
}, testOperator())
if err == nil {
t.Fatalf("expected conflict batch generate to fail")
}
var count int64
db.Model(&models.AgentTeamSchedule{}).Where("remark = ?", "不应创建").Count(&count)
if count != 0 {
t.Fatalf("expected no partial creates, got %d", count)
}
}
func setupAgentTeamScheduleTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AgentTeam{}, &models.AgentTeamSchedule{}); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}
func createAgentTeamScheduleTestData(t *testing.T, db *gorm.DB) {
t.Helper()
createAgentTeamScheduleTestTeams(t, db)
parse := func(value string) time.Time {
t.Helper()
ret, err := time.ParseInLocation(time.DateTime, value, time.Local)
if err != nil {
t.Fatalf("parse time %q error = %v", value, err)
}
return ret
}
schedules := []models.AgentTeamSchedule{
{ID: 1, TeamID: 1, StartAt: parse("2026-04-26 20:00:00"), EndAt: parse("2026-04-27 10:00:00"), Status: enums.StatusOk},
{ID: 2, TeamID: 1, StartAt: parse("2026-04-28 09:00:00"), EndAt: parse("2026-04-28 18:00:00"), Status: enums.StatusOk},
{ID: 3, TeamID: 2, StartAt: parse("2026-05-03 20:00:00"), EndAt: parse("2026-05-04 08:00:00"), Status: enums.StatusOk},
{ID: 4, TeamID: 1, StartAt: parse("2026-04-20 09:00:00"), EndAt: parse("2026-04-20 18:00:00"), Status: enums.StatusOk},
{ID: 5, TeamID: 2, StartAt: parse("2026-05-04 09:00:00"), EndAt: parse("2026-05-04 18:00:00"), Status: enums.StatusOk},
}
if err := db.Create(&schedules).Error; err != nil {
t.Fatalf("create schedules error = %v", err)
}
}
func createAgentTeamScheduleTestTeams(t *testing.T, db *gorm.DB) {
t.Helper()
teams := []models.AgentTeam{
{ID: 1, Name: "售前组", Status: enums.StatusOk},
{ID: 2, Name: "售后组", Status: enums.StatusOk},
}
if err := db.Create(&teams).Error; err != nil {
t.Fatalf("create teams error = %v", err)
}
}
func formatTestDateTime(date time.Time, clock string) string {
return date.Format(time.DateOnly) + " " + clock
}
func nextTestWeekday(target time.Weekday) time.Time {
ret := startOfTestDay(time.Now()).AddDate(0, 0, 1)
for ret.Weekday() != target {
ret = ret.AddDate(0, 0, 1)
}
return ret
}
func startOfTestDay(value time.Time) time.Time {
year, month, day := value.In(time.Local).Date()
return time.Date(year, month, day, 0, 0, 0, 0, time.Local)
}
func weekdayForRequest(value time.Time) int {
if value.Weekday() == time.Sunday {
return 7
}
return int(value.Weekday())
}
func createFutureAgentTeamSchedule(t *testing.T, db *gorm.DB) int64 {
t.Helper()
tomorrow := time.Now().AddDate(0, 0, 1)
item := models.AgentTeamSchedule{
TeamID: 1,
StartAt: parseTestDateTime(t, formatTestDateTime(tomorrow, "09:00:00")),
EndAt: parseTestDateTime(t, formatTestDateTime(tomorrow, "18:00:00")),
Status: enums.StatusOk,
}
if err := db.Create(&item).Error; err != nil {
t.Fatalf("create future schedule error = %v", err)
}
return item.ID
}
func parseTestDateTime(t *testing.T, value string) time.Time {
t.Helper()
ret, err := time.ParseInLocation(time.DateTime, value, time.Local)
if err != nil {
t.Fatalf("parse time %q error = %v", value, err)
}
return ret
}
func testOperator() *dto.AuthPrincipal {
return &dto.AuthPrincipal{UserID: 1, Username: "tester", Status: enums.StatusOk}
}

View File

@@ -0,0 +1,173 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"strings"
"time"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
)
var AgentTeamService = newAgentTeamService()
func newAgentTeamService() *agentTeamService {
return &agentTeamService{}
}
type agentTeamService struct {
}
func (s *agentTeamService) Get(id int64) *models.AgentTeam {
return repositories.AgentTeamRepository.Get(sqls.DB(), id)
}
func (s *agentTeamService) Take(where ...interface{}) *models.AgentTeam {
return repositories.AgentTeamRepository.Take(sqls.DB(), where...)
}
func (s *agentTeamService) Find(cnd *sqls.Cnd) []models.AgentTeam {
return repositories.AgentTeamRepository.Find(sqls.DB(), cnd)
}
func (s *agentTeamService) FindOne(cnd *sqls.Cnd) *models.AgentTeam {
return repositories.AgentTeamRepository.FindOne(sqls.DB(), cnd)
}
func (s *agentTeamService) FindPageByParams(params *params.QueryParams) (list []models.AgentTeam, paging *sqls.Paging) {
return repositories.AgentTeamRepository.FindPageByParams(sqls.DB(), params)
}
func (s *agentTeamService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentTeam, paging *sqls.Paging) {
return repositories.AgentTeamRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *agentTeamService) Count(cnd *sqls.Cnd) int64 {
return repositories.AgentTeamRepository.Count(sqls.DB(), cnd)
}
func (s *agentTeamService) FindByIds(ids []int64) []models.AgentTeam {
return repositories.AgentTeamRepository.FindByIds(sqls.DB(), ids)
}
func (s *agentTeamService) Create(t *models.AgentTeam) error {
return repositories.AgentTeamRepository.Create(sqls.DB(), t)
}
func (s *agentTeamService) Update(t *models.AgentTeam) error {
return repositories.AgentTeamRepository.Update(sqls.DB(), t)
}
func (s *agentTeamService) Updates(id int64, columns map[string]interface{}) error {
return repositories.AgentTeamRepository.Updates(sqls.DB(), id, columns)
}
func (s *agentTeamService) UpdateColumn(id int64, name string, value interface{}) error {
return repositories.AgentTeamRepository.UpdateColumn(sqls.DB(), id, name, value)
}
func (s *agentTeamService) Delete(id int64) {
repositories.AgentTeamRepository.Delete(sqls.DB(), id)
}
func (s *agentTeamService) CreateAgentTeam(req request.CreateAgentTeamRequest, operator *dto.AuthPrincipal) (*models.AgentTeam, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
item, err := s.buildTeamModel(0, req.Name, req.LeaderUserID, req.Status, req.Description, req.Remark)
if err != nil {
return nil, err
}
item.AuditFields = utils.BuildAuditFields(operator)
if err := repositories.AgentTeamRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
return item, nil
}
func (s *agentTeamService) UpdateAgentTeam(req request.UpdateAgentTeamRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(req.ID)
if current == nil || current.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0169")
}
item, err := s.buildTeamModel(req.ID, req.Name, req.LeaderUserID, req.Status, req.Description, req.Remark)
if err != nil {
return err
}
now := time.Now()
return repositories.AgentTeamRepository.Updates(sqls.DB(), req.ID, map[string]any{
"name": item.Name,
"leader_user_id": item.LeaderUserID,
"status": item.Status,
"description": item.Description,
"remark": item.Remark,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": now,
})
}
func (s *agentTeamService) DeleteAgentTeam(id int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(id)
if current == nil || current.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0169")
}
if AgentProfileService.Take("team_id = ?", id) != nil {
return errorsx.ForbiddenI18n("error.e0167")
}
if AgentTeamScheduleService.Take("team_id = ?", id) != nil {
return errorsx.ForbiddenI18n("error.e0168")
}
if AIAgentService.Take(
"(team_ids = ? OR team_ids LIKE ? OR team_ids LIKE ? OR team_ids LIKE ?) AND status <> ?",
utils.JoinInt64s([]int64{id}),
utils.JoinInt64s([]int64{id})+",%",
"%,"+utils.JoinInt64s([]int64{id}),
"%,"+utils.JoinInt64s([]int64{id})+",%",
enums.StatusDeleted,
) != nil {
return errorsx.ForbiddenI18n("error.e0166")
}
return repositories.AgentTeamRepository.Updates(sqls.DB(), id, map[string]any{
"status": enums.StatusDeleted,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
func (s *agentTeamService) buildTeamModel(id int64, name string, leaderUserID int64, status int, description, remark string) (*models.AgentTeam, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errorsx.InvalidParamI18n("error.e0170")
}
if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil {
return nil, errorsx.InvalidParamI18n("error.e0171")
}
if leaderUserID > 0 && UserService.Get(leaderUserID) == nil {
return nil, errorsx.InvalidParamI18n("error.e0294")
}
if status != 0 && status != 1 {
return nil, errorsx.InvalidParamI18n("error.e0174")
}
return &models.AgentTeam{
Name: name,
LeaderUserID: leaderUserID,
Status: enums.Status(status),
Description: strings.TrimSpace(description),
Remark: strings.TrimSpace(remark),
}, nil
}

View File

@@ -0,0 +1,81 @@
package services
import (
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
const (
agentToolInvocationStatusRunning = "running"
agentToolInvocationStatusCompleted = "completed"
agentToolInvocationStatusFailed = "failed"
)
var AgentToolInvocationService = newAgentToolInvocationService()
type AgentToolInvocationClaim struct {
Item *models.AgentToolInvocation
Completed bool
Acquired bool
}
type agentToolInvocationService struct{}
func newAgentToolInvocationService() *agentToolInvocationService {
return &agentToolInvocationService{}
}
// Claim obtains the persistent idempotency boundary. A completed invocation
// can be returned to callers; an in-flight invocation is never executed again.
func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string) (*AgentToolInvocationClaim, error) {
toolCode = strings.TrimSpace(toolCode)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if conversationID <= 0 || toolCode == "" || idempotencyKey == "" {
return nil, nil
}
if item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); item != nil {
if item.Status == agentToolInvocationStatusCompleted {
return &AgentToolInvocationClaim{Item: item, Completed: true}, nil
}
if item.Status == agentToolInvocationStatusRunning {
return &AgentToolInvocationClaim{Item: item}, nil
}
if err := repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "updated_at": time.Now()}); err != nil {
return nil, err
}
item.Status, item.ErrorMessage = agentToolInvocationStatusRunning, ""
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
}
item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning}
if err := repositories.AgentToolInvocationRepository.Create(sqls.DB(), item); err != nil {
// A concurrent caller may have created the unique invocation first.
if existing := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); existing != nil {
return &AgentToolInvocationClaim{Item: existing, Completed: existing.Status == agentToolInvocationStatusCompleted}, nil
}
return nil, err
}
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
}
func (s *agentToolInvocationService) Complete(item *models.AgentToolInvocation, resultData string) error {
if item == nil || item.ID <= 0 {
return nil
}
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()})
}
func (s *agentToolInvocationService) Fail(item *models.AgentToolInvocation, cause error) error {
if item == nil || item.ID <= 0 {
return nil
}
message := ""
if cause != nil {
message = cause.Error()
}
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusFailed, "error_message": message, "updated_at": time.Now()})
}

View File

@@ -0,0 +1,65 @@
package services
import (
"strings"
"testing"
"agent-desk/internal/models"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentToolInvocationServiceReusesCompletedInvocation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
first, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
if err != nil || first == nil || first.Item == nil || first.Completed {
t.Fatalf("first claim = %#v, err=%v", first, err)
}
if err := AgentToolInvocationService.Complete(first.Item, `{"ticketId":40}`); err != nil {
t.Fatalf("complete invocation: %v", err)
}
second, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"ticketId":40}` {
t.Fatalf("second claim = %#v, err=%v", second, err)
}
}
func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
first, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
if err != nil {
t.Fatalf("first claim: %v", err)
}
if err := AgentToolInvocationService.Fail(first.Item, errTestToolInvocation); err != nil {
t.Fatalf("fail invocation: %v", err)
}
second, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
if err != nil || second == nil || second.Completed || second.Item.Status != agentToolInvocationStatusRunning || second.Item.ErrorMessage != "" {
t.Fatalf("retry claim = %#v, err=%v", second, err)
}
}
var errTestToolInvocation = &toolInvocationTestError{}
type toolInvocationTestError struct{}
func (e *toolInvocationTestError) Error() string { return "tool failed" }

View File

@@ -0,0 +1,39 @@
package services
import (
"testing"
"agent-desk/internal/pkg/dto/request"
)
func TestValidateMCPToolRiskPolicyRejectsTrustedToolOverride(t *testing.T) {
_, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
ToolCode: "system/server_time",
RiskLevel: "write",
RequireConfirmation: true,
})
if err == nil {
t.Fatal("expected trusted system tool policy override to be rejected")
}
item, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
ToolCode: "system/server_time",
RiskLevel: "read",
})
if err != nil {
t.Fatalf("validate trusted system tool policy: %v", err)
}
if item.Title != "获取当前时间" || item.RiskLevel != "read" || item.RequireConfirmation {
t.Fatalf("unexpected normalized trusted policy: %#v", item)
}
}
func TestValidateMCPToolRiskPolicyRequiresWriteConfirmation(t *testing.T) {
_, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
ToolCode: "crm/update_customer",
RiskLevel: "write",
})
if err == nil {
t.Fatal("expected write tool without confirmation to be rejected")
}
}

View File

@@ -0,0 +1,592 @@
package services
import (
"encoding/json"
"slices"
"strings"
"time"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/toolx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AIAgentService = newAIAgentService()
const defaultNewAgentRolloutPercent = 5
func newAIAgentService() *aIAgentService {
return &aIAgentService{}
}
type aIAgentService struct {
}
func (s *aIAgentService) Get(id int64) *models.AIAgent {
if id <= 0 {
return nil
}
return repositories.AIAgentRepository.Get(sqls.DB(), id)
}
func (s *aIAgentService) Take(where ...interface{}) *models.AIAgent {
return repositories.AIAgentRepository.Take(sqls.DB(), where...)
}
func (s *aIAgentService) Find(cnd *sqls.Cnd) []models.AIAgent {
return repositories.AIAgentRepository.Find(sqls.DB(), cnd)
}
func (s *aIAgentService) FindOne(cnd *sqls.Cnd) *models.AIAgent {
return repositories.AIAgentRepository.FindOne(sqls.DB(), cnd)
}
func (s *aIAgentService) FindPageByParams(params *params.QueryParams) (list []models.AIAgent, paging *sqls.Paging) {
return repositories.AIAgentRepository.FindPageByParams(sqls.DB(), params)
}
func (s *aIAgentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIAgent, paging *sqls.Paging) {
return repositories.AIAgentRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *aIAgentService) Count(cnd *sqls.Cnd) int64 {
return repositories.AIAgentRepository.Count(sqls.DB(), cnd)
}
func (s *aIAgentService) FindByIds(ids []int64) []models.AIAgent {
return repositories.AIAgentRepository.FindByIds(sqls.DB(), ids)
}
func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operator *dto.AuthPrincipal) (*models.AIAgent, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
item, err := s.buildAIAgentModel(0, req)
if err != nil {
return nil, err
}
item.Status = enums.StatusOk
item.SortNo = 0
item.AuditFields = utils.BuildAuditFields(operator)
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
return err
}
_, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
return err
}); err != nil {
return nil, err
}
return item, nil
}
func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(req.ID)
if current == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest)
if err != nil {
return err
}
columns := map[string]any{
"name": item.Name,
"description": item.Description,
"ai_config_id": item.AIConfigID,
"max_steps": item.MaxSteps,
"context_window": item.ContextWindow,
"tool_policy": item.ToolPolicy,
"knowledge_policy": item.KnowledgePolicy,
"service_mode": item.ServiceMode,
"system_prompt": item.SystemPrompt,
"welcome_message": item.WelcomeMessage,
"reply_timeout_seconds": item.ReplyTimeoutSeconds,
"rollout_percent": item.RolloutPercent,
"team_ids": item.TeamIDs,
"handoff_mode": item.HandoffMode,
"fallback_mode": item.FallbackMode,
"fallback_message": item.FallbackMessage,
"knowledge_ids": item.KnowledgeIDs,
"skill_ids": item.SkillIDs,
"allowed_mcp_tools": item.AllowedMCPTools,
"auto_handoff_on_failure": item.AutoHandoffOnFailure,
"handoff_message": item.HandoffMessage,
"off_hours_message": item.OffHoursMessage,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}
if item.RolloutPercent != current.RolloutPercent {
columns["previous_rollout_percent"] = current.RolloutPercent
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil {
return err
}
_, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
return err
})
}
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
current := s.Get(id)
if current == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
if ChannelService.Take("ai_agent_id = ?", id) != nil {
return errorsx.ForbiddenI18n("error.e0185")
}
return repositories.AIAgentRepository.Updates(sqls.DB(), id, map[string]any{
"status": enums.StatusDeleted,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
// PublishAIAgent snapshots the complete Agent capability set before it can
// receive traffic.
func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
var revision *models.AgentRevision
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
return err
}
var err error
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
if err != nil {
return err
}
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
"published_revision_id": revision.ID,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
if err != nil {
return nil, err
}
return revision, nil
}
func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIAgent) error {
if agent == nil || agent.AIConfigID <= 0 {
return errorsx.InvalidParam("ai agent model configuration is required before publishing")
}
config := repositories.AIConfigRepository.Get(db, agent.AIConfigID)
if config == nil || config.Status != enums.StatusOk {
return errorsx.InvalidParam("ai agent model configuration is unavailable")
}
if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil {
return err
}
var mcpTools []request.AIAgentMCPToolRequest
if raw := strings.TrimSpace(agent.AllowedMCPTools); raw != "" {
if err := json.Unmarshal([]byte(raw), &mcpTools); err != nil {
return errorsx.InvalidParam("ai agent MCP tools are invalid")
}
}
for _, id := range utils.SplitInt64s(agent.SkillIDs) {
skill := repositories.SkillDefinitionRepository.Get(db, id)
if skill == nil || skill.Status != enums.StatusOk {
return errorsx.InvalidParam("bound Skill is unavailable")
}
}
for _, item := range mcpTools {
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
if err != nil || definition.InputSchema == nil {
return errorsx.InvalidParam("ai agent MCP tool definition is unavailable")
}
if _, err := validateMCPToolRiskPolicy(item); err != nil {
return err
}
}
for _, binding := range s.ListEnabledWorkflowBindings(db, agent.ID) {
if binding.Version == nil || binding.Version.Status != enums.StatusOk {
return errorsx.InvalidParam("bound workflow version is unavailable")
}
}
return nil
}
// RollbackAIAgent switches an Agent back to a previously published immutable
// revision. It never rewrites the historical snapshot itself.
func (s *aIAgentService) RollbackAIAgent(id, revisionID int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if id <= 0 || revisionID <= 0 {
return errorsx.InvalidParam("agent id and revision id are required")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
revision := repositories.AgentRevisionRepository.Get(ctx.Tx, revisionID)
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
return errorsx.InvalidParam("agent revision does not exist")
}
updates := map[string]any{
"published_revision_id": revision.ID,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, updates)
})
}
// RollbackAIAgentRollout restores the prior Agent rollout percentage and
// swaps it into history, allowing operators to undo and redo one rollout
// change without rewriting an immutable AgentRevision.
func (s *aIAgentService) RollbackAIAgentRollout(id int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if id <= 0 {
return errorsx.InvalidParam("agent id is required")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
if agent.PreviousRolloutPercent < 1 || agent.PreviousRolloutPercent > 100 {
return errorsx.InvalidParam("agent rollout has no previous value to restore")
}
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
"rollout_percent": agent.PreviousRolloutPercent,
"previous_rollout_percent": agent.RolloutPercent,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
}
func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errorsx.InvalidParamI18n("error.e0005")
}
if exists := s.Take("name = ? AND id <> ?", name, id); exists != nil {
return nil, errorsx.InvalidParamI18n("error.e0006")
}
if req.AIConfigID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0010")
}
aiConfig := AIConfigService.Get(req.AIConfigID)
if aiConfig == nil {
return nil, errorsx.InvalidParamI18n("error.e0009")
}
if aiConfig.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0011")
}
if req.MaxSteps == 0 {
req.MaxSteps = 6
}
if req.MaxSteps < 1 || req.MaxSteps > 8 {
return nil, errorsx.InvalidParam("ai agent max steps must be between 1 and 8")
}
if req.ContextWindow < 0 {
return nil, errorsx.InvalidParam("ai agent context window must not be negative")
}
toolPolicy, err := s.normalizeToolPolicy(req.ToolPolicy)
if err != nil {
return nil, err
}
if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) {
return nil, errorsx.InvalidParamI18n("error.e0230")
}
teamIDs, err := s.normalizeTeamIDs(req.TeamIDs)
if err != nil {
return nil, err
}
if !slices.Contains(enums.AIAgentHandoffModeValues, enums.AIAgentHandoffMode(req.HandoffMode)) {
return nil, errorsx.InvalidParamI18n("error.e0336")
}
if req.FallbackMode == 0 {
req.FallbackMode = enums.AIAgentFallbackModeNoAnswer
}
if !slices.Contains(enums.AIAgentFallbackModeValues, enums.AIAgentFallbackMode(req.FallbackMode)) {
return nil, errorsx.InvalidParamI18n("error.e0123")
}
if enums.AIAgentHandoffMode(req.HandoffMode) == enums.AIAgentHandoffModeDefaultTeamPool && len(teamIDs) == 0 {
return nil, errorsx.InvalidParamI18n("error.e0347")
}
if req.ReplyTimeoutSeconds < 0 {
return nil, errorsx.InvalidParamI18n("error.e0144")
}
if req.RolloutPercent == 0 {
req.RolloutPercent = defaultNewAgentRolloutPercent
}
if req.RolloutPercent < 1 || req.RolloutPercent > 100 {
return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100")
}
skillIDs, err := s.normalizeSkillIDs(req.SkillIDs)
if err != nil {
return nil, err
}
knowledgeBaseIDs, err := s.normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)
if err != nil {
return nil, err
}
mcpTools, err := s.normalizeMCPTools(req.MCPTools)
if err != nil {
return nil, err
}
mcpToolsJSON := ""
if len(mcpTools) > 0 {
buf, marshalErr := json.Marshal(mcpTools)
if marshalErr != nil {
return nil, errorsx.InvalidParamI18n("error.e0021")
}
mcpToolsJSON = string(buf)
}
autoHandoffOnFailure := true
if req.AutoHandoffOnFailure != nil {
autoHandoffOnFailure = *req.AutoHandoffOnFailure
}
return &models.AIAgent{
Name: name,
Description: strings.TrimSpace(req.Description),
AIConfigID: req.AIConfigID,
MaxSteps: req.MaxSteps,
ContextWindow: req.ContextWindow,
ToolPolicy: toolPolicy,
KnowledgePolicy: strings.TrimSpace(req.KnowledgePolicy),
ServiceMode: req.ServiceMode,
SystemPrompt: strings.TrimSpace(req.SystemPrompt),
WelcomeMessage: strings.TrimSpace(req.WelcomeMessage),
ReplyTimeoutSeconds: req.ReplyTimeoutSeconds,
RolloutPercent: req.RolloutPercent,
TeamIDs: utils.JoinInt64s(teamIDs),
HandoffMode: req.HandoffMode,
FallbackMode: req.FallbackMode,
FallbackMessage: strings.TrimSpace(req.FallbackMessage),
KnowledgeIDs: utils.JoinInt64s(knowledgeBaseIDs),
SkillIDs: utils.JoinInt64s(skillIDs),
AllowedMCPTools: mcpToolsJSON,
AutoHandoffOnFailure: autoHandoffOnFailure,
HandoffMessage: strings.TrimSpace(req.HandoffMessage),
OffHoursMessage: strings.TrimSpace(req.OffHoursMessage),
}, nil
}
type normalizedAIAgentToolPolicy struct {
MaxTotalCalls int `json:"maxTotalCalls,omitempty"`
MaxArgumentBytes int `json:"maxArgumentBytes,omitempty"`
AllowedRiskLevels []string `json:"allowedRiskLevels,omitempty"`
}
func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
policy := normalizedAIAgentToolPolicy{}
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
return "", errorsx.InvalidParam("ai agent tool policy must be valid JSON")
}
if policy.MaxTotalCalls < 0 || policy.MaxTotalCalls > 8 {
return "", errorsx.InvalidParam("ai agent tool policy maxTotalCalls must be between 1 and 8")
}
if policy.MaxArgumentBytes < 0 || policy.MaxArgumentBytes > 64*1024 {
return "", errorsx.InvalidParam("ai agent tool policy maxArgumentBytes must be between 1 and 65536")
}
seen := make(map[string]struct{}, len(policy.AllowedRiskLevels))
riskLevels := make([]string, 0, len(policy.AllowedRiskLevels))
for _, level := range policy.AllowedRiskLevels {
level = strings.ToLower(strings.TrimSpace(level))
if level == "" {
continue
}
if level != "read" && level != "write" {
return "", errorsx.InvalidParam("ai agent tool policy contains an invalid risk level")
}
if _, exists := seen[level]; exists {
continue
}
seen[level] = struct{}{}
riskLevels = append(riskLevels, level)
}
policy.AllowedRiskLevels = riskLevels
data, err := json.Marshal(policy)
if err != nil {
return "", errorsx.InvalidParam("ai agent tool policy is invalid")
}
return string(data), nil
}
func (s *aIAgentService) normalizeKnowledgeBaseIDs(input []int64) ([]int64, error) {
ret := make([]int64, 0, len(input))
seen := make(map[int64]struct{})
for _, id := range input {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
knowledgeBase := KnowledgeBaseService.Get(id)
if knowledgeBase == nil || knowledgeBase.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("knowledge base is not available")
}
seen[id] = struct{}{}
ret = append(ret, id)
}
slices.Sort(ret)
return ret, nil
}
func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) {
ret := make([]int64, 0, len(input))
seen := make(map[int64]struct{})
for _, id := range input {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
team := AgentTeamService.Get(id)
if team == nil || team.Status == enums.StatusDeleted {
continue
}
// if team.Status != enums.StatusOk {
// return nil, errorsx.InvalidParamI18n("error.e0173")
// }
seen[id] = struct{}{}
ret = append(ret, id)
}
slices.Sort(ret)
return ret, nil
}
func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) {
ret := make([]int64, 0, len(input))
seen := make(map[int64]struct{})
for _, id := range input {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
skill := SkillDefinitionService.Get(id)
if skill == nil || skill.Status == enums.StatusDeleted {
continue
}
if skill.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0056")
}
seen[id] = struct{}{}
ret = append(ret, id)
}
return ret, nil
}
func (s *aIAgentService) normalizeMCPTools(input []request.AIAgentMCPToolRequest) ([]request.AIAgentMCPToolRequest, error) {
if len(input) == 0 {
return nil, nil
}
ret := make([]request.AIAgentMCPToolRequest, 0, len(input))
seen := make(map[string]struct{})
for _, item := range input {
normalized, err := toolx.NormalizeMCPToolRequest(item)
if err != nil {
return nil, err
}
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP {
return nil, errorsx.InvalidParamI18n("error.e0020")
}
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
return nil, err
}
normalized.RiskLevel = strings.ToLower(strings.TrimSpace(item.RiskLevel))
normalized.RequireConfirmation = item.RequireConfirmation
normalized, err = validateMCPToolRiskPolicy(normalized)
if err != nil {
return nil, err
}
key := strings.TrimSpace(normalized.ToolCode)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
ret = append(ret, normalized)
}
return ret, nil
}
func validateMCPToolRiskPolicy(item request.AIAgentMCPToolRequest) (request.AIAgentMCPToolRequest, error) {
if policy, ok := toolx.GetTrustedMCPToolPolicy(item.ToolCode); ok {
if item.RiskLevel != policy.RiskLevel || item.RequireConfirmation != policy.RequireConfirmation {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("system MCP tool risk policy cannot be changed")
}
return toolx.ApplyTrustedMCPToolPolicy(item), nil
}
if item.RiskLevel != aitooling.RiskLevelRead && item.RiskLevel != aitooling.RiskLevelWrite {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("MCP tool risk level must be read or write")
}
if item.RiskLevel == aitooling.RiskLevelWrite && !item.RequireConfirmation {
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("write MCP tools must require confirmation")
}
return item, nil
}
func (s *aIAgentService) UpdateSort(ids []int64) error {
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
for i, id := range ids {
if err := repositories.AIAgentRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil {
return err
}
}
return nil
})
}
func (s *aIAgentService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(id)
if current == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
return errorsx.InvalidParamI18n("error.e0254")
}
return repositories.AIAgentRepository.Updates(sqls.DB(), id, map[string]any{
"status": status,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}

View File

@@ -0,0 +1,80 @@
package services
import (
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, dbErr := db.DB()
if dbErr == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.AIAgentWorkflowBinding{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
config := &models.AIConfig{
Name: "test", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI,
ModelType: enums.AIModelTypeLLM, ModelName: "test-model",
}
if err := db.Create(config).Error; err != nil {
t.Fatalf("create ai config: %v", err)
}
agent := &models.AIAgent{
Name: "published agent", Status: enums.StatusOk, AIConfigID: config.ID,
ServiceMode: enums.IMConversationServiceModeAIFirst, HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer, RolloutPercent: 100, PublishedRevisionID: 18,
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create ai agent: %v", err)
}
err = AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{
ID: agent.ID,
CreateAIAgentRequest: request.CreateAIAgentRequest{
Name: "updated draft", AIConfigID: config.ID,
ServiceMode: enums.IMConversationServiceModeAIFirst,
HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
RolloutPercent: 100,
},
}, &dto.AuthPrincipal{UserID: 1, Username: "admin"})
if err != nil {
t.Fatalf("UpdateAIAgent: %v", err)
}
var updated models.AIAgent
if err := db.First(&updated, agent.ID).Error; err != nil {
t.Fatalf("get updated ai agent: %v", err)
}
if updated.PublishedRevisionID != agent.PublishedRevisionID {
t.Fatalf("published revision id = %d, want %d", updated.PublishedRevisionID, agent.PublishedRevisionID)
}
if updated.Name != "updated draft" {
t.Fatalf("draft name = %q, want updated draft", updated.Name)
}
}

View File

@@ -0,0 +1,70 @@
package services
import (
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
type AIAgentWorkflowBindingContext struct {
Binding models.AIAgentWorkflowBinding
Workflow *models.AIWorkflow
Version *models.AIWorkflowVersion
}
func (s *aIAgentService) ListWorkflowBindings(agentID int64) []AIAgentWorkflowBindingContext {
bindings := repositories.AIAgentWorkflowBindingRepository.FindByAgentID(sqls.DB(), agentID)
return s.buildWorkflowBindingContexts(sqls.DB(), bindings)
}
func (s *aIAgentService) ListEnabledWorkflowBindings(db *gorm.DB, agentID int64) []AIAgentWorkflowBindingContext {
return s.buildWorkflowBindingContexts(db, repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agentID))
}
func (s *aIAgentService) buildWorkflowBindingContexts(db *gorm.DB, bindings []models.AIAgentWorkflowBinding) []AIAgentWorkflowBindingContext {
ret := make([]AIAgentWorkflowBindingContext, 0, len(bindings))
for _, binding := range bindings {
ret = append(ret, AIAgentWorkflowBindingContext{Binding: binding, Workflow: repositories.AIWorkflowRepository.Get(db, binding.WorkflowID), Version: repositories.AIWorkflowVersionRepository.Get(db, binding.WorkflowVersionID)})
}
return ret
}
func (s *aIAgentService) replaceWorkflowBindings(db *gorm.DB, agentID int64, input []request.AIAgentWorkflowBindingRequest, operator *dto.AuthPrincipal) ([]models.AIAgentWorkflowBinding, error) {
seen := make(map[int64]struct{}, len(input))
items := make([]models.AIAgentWorkflowBinding, 0, len(input))
for index, item := range input {
if item.WorkflowVersionID <= 0 {
return nil, errorsx.InvalidParam("workflow version is required")
}
if _, exists := seen[item.WorkflowVersionID]; exists {
return nil, errorsx.InvalidParam("workflow version must not be bound more than once")
}
seen[item.WorkflowVersionID] = struct{}{}
version := repositories.AIWorkflowVersionRepository.Get(db, item.WorkflowVersionID)
if version == nil || version.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("workflow version is not published")
}
workflow := repositories.AIWorkflowRepository.Get(db, version.WorkflowID)
if workflow == nil || workflow.Status == enums.StatusDeleted {
return nil, errorsx.InvalidParam("workflow does not exist")
}
priority := item.Priority
if priority == 0 {
priority = index + 1
}
items = append(items, models.AIAgentWorkflowBinding{AIAgentID: agentID, WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ToolName: strings.TrimSpace(item.ToolName), TriggerInstruction: strings.TrimSpace(item.TriggerInstruction), Priority: priority, Enabled: item.Enabled, AuditFields: utils.BuildAuditFields(operator)})
}
if err := repositories.AIAgentWorkflowBindingRepository.ReplaceByAgentID(db, agentID, items); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,268 @@
package services
import (
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls"
)
var AIConfigService = newAIConfigService()
func newAIConfigService() *aIConfigService {
return &aIConfigService{}
}
type aIConfigService struct {
}
func (s *aIConfigService) Get(id int64) *models.AIConfig {
return repositories.AIConfigRepository.Get(sqls.DB(), id)
}
func (s *aIConfigService) Take(where ...interface{}) *models.AIConfig {
return repositories.AIConfigRepository.Take(sqls.DB(), where...)
}
func (s *aIConfigService) Find(cnd *sqls.Cnd) []models.AIConfig {
return repositories.AIConfigRepository.Find(sqls.DB(), cnd)
}
func (s *aIConfigService) FindOne(cnd *sqls.Cnd) *models.AIConfig {
return repositories.AIConfigRepository.FindOne(sqls.DB(), cnd)
}
func (s *aIConfigService) FindPageByParams(params *params.QueryParams) (list []models.AIConfig, paging *sqls.Paging) {
return repositories.AIConfigRepository.FindPageByParams(sqls.DB(), params)
}
func (s *aIConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIConfig, paging *sqls.Paging) {
return repositories.AIConfigRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *aIConfigService) Count(cnd *sqls.Cnd) int64 {
return repositories.AIConfigRepository.Count(sqls.DB(), cnd)
}
func (s *aIConfigService) Create(t *models.AIConfig) error {
return repositories.AIConfigRepository.Create(sqls.DB(), t)
}
func (s *aIConfigService) Update(t *models.AIConfig) error {
return repositories.AIConfigRepository.Update(sqls.DB(), t)
}
func (s *aIConfigService) Updates(id int64, columns map[string]interface{}) error {
return repositories.AIConfigRepository.Updates(sqls.DB(), id, columns)
}
func (s *aIConfigService) UpdateColumn(id int64, name string, value interface{}) error {
return repositories.AIConfigRepository.UpdateColumn(sqls.DB(), id, name, value)
}
func (s *aIConfigService) Delete(id int64) {
repositories.AIConfigRepository.Delete(sqls.DB(), id)
}
func (s *aIConfigService) CreateAIConfig(req request.CreateAIConfigRequest, operator *dto.AuthPrincipal) (*models.AIConfig, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
item, err := s.buildAIConfigModel(req)
if err != nil {
return nil, err
}
item.Status = enums.StatusDisabled
item.SortNo = s.nextSortNo()
item.AuditFields = utils.BuildAuditFields(operator)
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := ctx.Tx.Create(item).Error; err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return item, nil
}
func (s *aIConfigService) UpdateAIConfig(req request.UpdateAIConfigRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(req.ID)
if current == nil {
return errorsx.InvalidParamI18n("error.e0012")
}
item, err := s.buildAIConfigModel(req.CreateAIConfigRequest)
if err != nil {
return err
}
columns := map[string]any{
"name": item.Name,
"provider": item.Provider,
"base_url": item.BaseURL,
"model_type": item.ModelType,
"model_name": item.ModelName,
"dimension": item.Dimension,
"max_context_tokens": item.MaxContextTokens,
"max_output_tokens": item.MaxOutputTokens,
"timeout_ms": item.TimeoutMS,
"max_retry_count": item.MaxRetryCount,
"rpm_limit": item.RPMLimit,
"tpm_limit": item.TPMLimit,
"remark": item.Remark,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}
if item.APIKey != "" {
columns["api_key"] = item.APIKey
}
return repositories.AIConfigRepository.Updates(sqls.DB(), req.ID, columns)
}
func (s *aIConfigService) DeleteAIConfig(id int64, operator *dto.AuthPrincipal) error {
current := s.Get(id)
if current == nil {
return nil
}
if current.Status == enums.StatusOk {
return errorsx.ForbiddenI18n("error.e0143")
}
return repositories.AIConfigRepository.Updates(sqls.DB(), id, map[string]any{
"status": enums.StatusDeleted,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
func (s *aIConfigService) UpdateStatus(id int64, status enums.Status, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
current := s.Get(id)
if current == nil {
return errorsx.InvalidParamI18n("error.e0012")
}
if status != enums.StatusOk && status != enums.StatusDisabled {
return errorsx.InvalidParamI18n("error.e0254")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if status == enums.StatusOk {
if err := s.disableOthersByModelType(ctx, current.ModelType, id); err != nil {
return err
}
}
return repositories.AIConfigRepository.Updates(ctx.Tx, id, map[string]any{
"status": status,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
}
func (s *aIConfigService) disableOthersByModelType(ctx *sqls.TxContext, modelType enums.AIModelType, excludeID int64) error {
query := ctx.Tx.Model(&models.AIConfig{}).Where("model_type = ?", modelType)
if excludeID > 0 {
query = query.Where("id <> ?", excludeID)
}
return query.Updates(map[string]any{
"status": int(enums.StatusDisabled),
"updated_at": time.Now(),
}).Error
}
func (s *aIConfigService) UpdateSort(ids []int64) error {
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
for i, id := range ids {
if err := repositories.AIConfigRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil {
return err
}
}
return nil
})
}
func (s *aIConfigService) buildAIConfigModel(req request.CreateAIConfigRequest) (*models.AIConfig, error) {
name := strings.TrimSpace(req.Name)
baseURL := strings.TrimSpace(req.BaseURL)
modelName := strings.TrimSpace(req.ModelName)
if name == "" {
return nil, errorsx.InvalidParamI18n("error.e0339")
}
if strs.IsBlank(string(req.Provider)) {
return nil, errorsx.InvalidParamI18n("error.e0122")
}
if baseURL == "" {
return nil, errorsx.InvalidParamI18n("error.e0147")
}
if strs.IsBlank(string(req.ModelType)) {
return nil, errorsx.InvalidParamI18n("error.e0243")
}
if modelName == "" {
return nil, errorsx.InvalidParamI18n("error.e0242")
}
if req.Dimension < 0 {
req.Dimension = 0
}
if req.MaxContextTokens < 0 {
req.MaxContextTokens = 0
}
if req.MaxOutputTokens < 0 {
req.MaxOutputTokens = 0
}
if req.TimeoutMS <= 0 {
req.TimeoutMS = 30000
}
if req.MaxRetryCount < 0 {
req.MaxRetryCount = 0
}
if req.RPMLimit < 0 {
req.RPMLimit = 0
}
if req.TPMLimit < 0 {
req.TPMLimit = 0
}
return &models.AIConfig{
Name: name,
Provider: req.Provider,
BaseURL: baseURL,
APIKey: strings.TrimSpace(req.APIKey),
ModelType: req.ModelType,
ModelName: modelName,
Dimension: req.Dimension,
MaxContextTokens: req.MaxContextTokens,
MaxOutputTokens: req.MaxOutputTokens,
TimeoutMS: req.TimeoutMS,
MaxRetryCount: req.MaxRetryCount,
RPMLimit: req.RPMLimit,
TPMLimit: req.TPMLimit,
Remark: strings.TrimSpace(req.Remark),
}, nil
}
func (s *aIConfigService) nextSortNo() int {
if latest := s.FindOne(sqls.NewCnd().Desc("sort_no").Desc("id")); latest != nil {
return latest.SortNo + 1
}
return 1
}

View File

@@ -0,0 +1,82 @@
package services
import (
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestUpdateAIConfigKeepsAPIKeyWhenRequestAPIKeyBlank(t *testing.T) {
db := setupAIConfigServiceTestDB(t)
item := &models.AIConfig{
Name: "old",
Provider: enums.AIProviderOpenAI,
BaseURL: "https://old.example.com",
APIKey: "sk-existing",
ModelType: enums.AIModelTypeLLM,
ModelName: "old-model",
TimeoutMS: 30000,
AuditFields: models.AuditFields{CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
if err := db.Create(item).Error; err != nil {
t.Fatalf("create ai config error = %v", err)
}
err := AIConfigService.UpdateAIConfig(request.UpdateAIConfigRequest{
ID: item.ID,
CreateAIConfigRequest: request.CreateAIConfigRequest{
Name: "new",
Provider: enums.AIProviderOpenAI,
BaseURL: "https://new.example.com",
APIKey: " ",
ModelType: enums.AIModelTypeLLM,
ModelName: "new-model",
TimeoutMS: 120000,
},
}, &dto.AuthPrincipal{UserID: 1, Username: "admin"})
if err != nil {
t.Fatalf("UpdateAIConfig() error = %v", err)
}
var updated models.AIConfig
if err := db.First(&updated, item.ID).Error; err != nil {
t.Fatalf("get updated ai config error = %v", err)
}
if updated.APIKey != "sk-existing" {
t.Fatalf("expected api key to be preserved, got %q", updated.APIKey)
}
}
func setupAIConfigServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AIConfig{}); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}

View File

@@ -0,0 +1,210 @@
package services
import (
"context"
"fmt"
"slices"
"strings"
"agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
var AICopilotService = newAICopilotService()
func newAICopilotService() *aiCopilotService {
return &aiCopilotService{
chatFn: ai.LLM.Chat,
}
}
type aiCopilotService struct {
chatFn func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error)
}
// SetChatFnForTest allows tests outside package services to override LLM chat logic.
func (s *aiCopilotService) SetChatFnForTest(fn func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error)) func() {
prev := s.chatFn
s.chatFn = fn
return func() {
s.chatFn = prev
}
}
// SuggestReply analyzes recent conversation messages and produces a suggested draft reply for human operators.
func (s *aiCopilotService) SuggestReply(ctx context.Context, conversationID int64, locale string) (string, error) {
if conversationID <= 0 {
return "", errorsx.InvalidParamI18n("error.e0064")
}
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return "", errorsx.NotFoundI18n("error.e0116")
}
messages := repositories.MessageRepository.Find(
sqls.DB(),
sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id").Limit(12),
)
if len(messages) == 0 {
return "", errorsx.InvalidParamI18n("error.e0245")
}
slices.Reverse(messages)
transcript := s.buildConversationTranscript(messages)
if strings.TrimSpace(transcript) == "" {
return "", errorsx.InvalidParamI18n("error.e0245")
}
systemPrompt, userPrompt := s.buildSuggestReplyPrompts(transcript, locale)
result, err := s.chat(ctx, systemPrompt, userPrompt)
if err != nil {
return "", err
}
if result == nil || strings.TrimSpace(result.Content) == "" {
return "", fmt.Errorf("empty reply suggestion from model")
}
return result.Content, nil
}
// SummarizeConversation produces a structured summary of the conversation for handoffs or resolution records.
func (s *aiCopilotService) SummarizeConversation(ctx context.Context, conversationID int64, locale string) (string, error) {
if conversationID <= 0 {
return "", errorsx.InvalidParamI18n("error.e0064")
}
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return "", errorsx.NotFoundI18n("error.e0116")
}
messages := repositories.MessageRepository.Find(
sqls.DB(),
sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id").Limit(20),
)
if len(messages) == 0 {
return "", errorsx.InvalidParamI18n("error.e0245")
}
slices.Reverse(messages)
transcript := s.buildConversationTranscript(messages)
if strings.TrimSpace(transcript) == "" {
return "", errorsx.InvalidParamI18n("error.e0245")
}
systemPrompt, userPrompt := s.buildSummarizePrompts(transcript, locale)
result, err := s.chat(ctx, systemPrompt, userPrompt)
if err != nil {
return "", err
}
if result == nil || strings.TrimSpace(result.Content) == "" {
return "", fmt.Errorf("empty summary from model")
}
return result.Content, nil
}
func (s *aiCopilotService) chat(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
if s.chatFn != nil {
return s.chatFn(ctx, systemPrompt, userPrompt)
}
return ai.LLM.Chat(ctx, systemPrompt, userPrompt)
}
func (s *aiCopilotService) buildConversationTranscript(messages []models.Message) string {
var lines []string
for _, msg := range messages {
if msg.RecalledAt != nil || msg.SendStatus == enums.IMMessageStatusRecalled {
continue
}
text := s.extractMessageText(msg)
if text == "" {
continue
}
switch msg.SenderType {
case enums.IMSenderTypeCustomer:
lines = append(lines, "Customer: "+text)
case enums.IMSenderTypeAgent, enums.IMSenderTypeAI:
lines = append(lines, "Agent: "+text)
case enums.IMSenderTypeSystem:
// System notifications are intentionally excluded from operator transcript
}
}
return strings.Join(lines, "\n")
}
func (s *aiCopilotService) extractMessageText(msg models.Message) string {
content := strings.TrimSpace(msg.Content)
var text string
switch msg.MessageType {
case enums.IMMessageTypeHTML:
summary := utils.BuildHTMLSummary(content)
if summary != "" {
text = summary
} else {
text = content
}
case enums.IMMessageTypeImage:
if content != "" {
text = fmt.Sprintf("[Image: %s]", content)
} else {
text = "[Image]"
}
case enums.IMMessageTypeAttachment:
if content != "" {
text = fmt.Sprintf("[Attachment: %s]", content)
} else {
text = "[Attachment]"
}
default:
text = content
}
runes := []rune(text)
if len(runes) > 2000 {
return string(runes[:2000])
}
return text
}
func (s *aiCopilotService) buildSuggestReplyPrompts(transcript, locale string) (string, string) {
normalized := i18nx.NormalizeLocale(locale)
switch normalized {
case i18nx.LocaleZhCN:
systemPrompt := "你是一位专业的智能客服助手。请根据对话历史,为客服人员草拟一条针对客户最新诉求的专业、礼貌、准确且简洁的回复建议。回复语言必须与客户在对话中使用的语言保持一致。"
userPrompt := "对话记录:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\n请草拟一条回复建议:"
return systemPrompt, userPrompt
case i18nx.LocaleRuRU:
systemPrompt := "Вы — эксперт и ассистент оператора службы поддержки клиентов. На основе истории диалога составьте полезный, профессиональный, вежливый и краткий вариант ответа на последний запрос клиента. Ответ должен быть на том языке, на котором пишет клиент."
userPrompt := "История диалога:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nПредложите вариант ответа:"
return systemPrompt, userPrompt
default:
systemPrompt := "You are an expert customer support co-pilot. Based on the conversation history, draft a helpful, professional, polite, and concise reply to the customer's latest inquiry. Reply in the language used by the customer in the conversation."
userPrompt := "Conversation history:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nDraft a suggested reply:"
return systemPrompt, userPrompt
}
}
func (s *aiCopilotService) buildSummarizePrompts(transcript, locale string) (string, string) {
normalized := i18nx.NormalizeLocale(locale)
switch normalized {
case i18nx.LocaleZhCN:
systemPrompt := "你是一位专业的客服对话总结助手。请提供此客服对话的简明扼要总结,包含:1. 核心问题/诉求,2. 已采取的措施/已提供的解答,3. 当前状态/后续跟进事项。"
userPrompt := "对话记录:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\n请总结此对话:"
return systemPrompt, userPrompt
case i18nx.LocaleRuRU:
systemPrompt := "Вы — ассистент по подведению итогов диалогов службы поддержки. Предоставьте краткую сводку диалога, включая: 1. Основная проблема / вопрос, 2. Принятые меры / предоставленные ответы, 3. Текущий статус / следующие шаги."
userPrompt := "История диалога:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nПодведите итог диалога:"
return systemPrompt, userPrompt
default:
systemPrompt := "You are a customer support summarizer. Provide a concise summary of this customer support conversation including: 1. Main Issue / Inquiry, 2. Actions taken / Answers provided, 3. Current Status / Next Steps."
userPrompt := "Conversation history:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nSummarize this conversation:"
return systemPrompt, userPrompt
}
}

View File

@@ -0,0 +1,414 @@
package services
import (
"context"
"errors"
"strings"
"testing"
"time"
"agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func setupAICopilotTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.Conversation{}, &models.Message{}); err != nil {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
return db
}
func TestAICopilotService_BuildConversationTranscript(t *testing.T) {
recalledTime := time.Now()
messages := []models.Message{
{
ID: 1,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "Hello, I need help with my account",
},
{
ID: 2,
SenderType: enums.IMSenderTypeAgent,
MessageType: enums.IMMessageTypeText,
Content: "Hi there! What issue are you experiencing?",
},
{
ID: 3,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeHTML,
Content: "<p>I see an <b>error code 500</b> on checkout.</p>",
},
{
ID: 4,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeImage,
Content: "screenshot.png",
},
{
ID: 5,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeAttachment,
Content: "log.txt",
},
{
ID: 6,
SenderType: enums.IMSenderTypeAgent,
MessageType: enums.IMMessageTypeText,
Content: "This was recalled",
RecalledAt: &recalledTime,
SendStatus: enums.IMMessageStatusRecalled,
},
{
ID: 7,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: " ", // empty content should be ignored
},
{
ID: 8,
SenderType: enums.IMSenderTypeAI,
MessageType: enums.IMMessageTypeText,
Content: "Our system is checking your account now.",
},
}
svc := newAICopilotService()
transcript := svc.buildConversationTranscript(messages)
expectedLines := []string{
"Customer: Hello, I need help with my account",
"Agent: Hi there! What issue are you experiencing?",
"Customer: I see an error code 500 on checkout.",
"Customer: [Image: screenshot.png]",
"Customer: [Attachment: log.txt]",
"Agent: Our system is checking your account now.",
}
for _, line := range expectedLines {
if !strings.Contains(transcript, line) {
t.Errorf("transcript missing expected line: %q\nFull transcript:\n%s", line, transcript)
}
}
if strings.Contains(transcript, "This was recalled") {
t.Errorf("recalled message should not appear in transcript: %s", transcript)
}
}
func TestAICopilotService_BuildSuggestReplyPrompts_Locales(t *testing.T) {
svc := newAICopilotService()
transcript := "Customer: How do I change password?"
locales := []struct {
locale string
wantSystemContent string
wantUserPrefix string
}{
{
locale: i18nx.LocaleEnUS,
wantSystemContent: "expert customer support co-pilot",
wantUserPrefix: "Conversation history:",
},
{
locale: i18nx.LocaleZhCN,
wantSystemContent: "智能客服助手",
wantUserPrefix: "对话记录:",
},
{
locale: i18nx.LocaleRuRU,
wantSystemContent: "ассистент оператора службы поддержки",
wantUserPrefix: "История диалога:",
},
}
for _, tc := range locales {
t.Run(tc.locale, func(t *testing.T) {
sysPrompt, usrPrompt := svc.buildSuggestReplyPrompts(transcript, tc.locale)
if !strings.Contains(sysPrompt, tc.wantSystemContent) {
t.Errorf("sysPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantSystemContent, sysPrompt)
}
if !strings.Contains(usrPrompt, tc.wantUserPrefix) {
t.Errorf("usrPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantUserPrefix, usrPrompt)
}
if !strings.Contains(usrPrompt, "<conversation_history>\n"+transcript+"\n</conversation_history>") {
t.Errorf("usrPrompt for locale %s did not contain tagged transcript, got: %s", tc.locale, usrPrompt)
}
})
}
}
func TestAICopilotService_BuildSummarizePrompts_Locales(t *testing.T) {
svc := newAICopilotService()
transcript := "Customer: My account is locked.\nAgent: Let me unlock it."
locales := []struct {
locale string
wantSystemContent string
wantUserPrefix string
}{
{
locale: i18nx.LocaleEnUS,
wantSystemContent: "customer support summarizer",
wantUserPrefix: "Conversation history:",
},
{
locale: i18nx.LocaleZhCN,
wantSystemContent: "客服对话总结助手",
wantUserPrefix: "对话记录:",
},
{
locale: i18nx.LocaleRuRU,
wantSystemContent: "ассистент по подведению итогов",
wantUserPrefix: "История диалога:",
},
}
for _, tc := range locales {
t.Run(tc.locale, func(t *testing.T) {
sysPrompt, usrPrompt := svc.buildSummarizePrompts(transcript, tc.locale)
if !strings.Contains(sysPrompt, tc.wantSystemContent) {
t.Errorf("sysPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantSystemContent, sysPrompt)
}
if !strings.Contains(usrPrompt, tc.wantUserPrefix) {
t.Errorf("usrPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantUserPrefix, usrPrompt)
}
if !strings.Contains(usrPrompt, "<conversation_history>\n"+transcript+"\n</conversation_history>") {
t.Errorf("usrPrompt for locale %s did not contain tagged transcript, got: %s", tc.locale, usrPrompt)
}
})
}
}
func TestAICopilotService_ExtractMessageText_RuneLimit(t *testing.T) {
svc := newAICopilotService()
longText := strings.Repeat("a", 2500)
msg := models.Message{
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: longText,
}
extracted := svc.extractMessageText(msg)
if len([]rune(extracted)) != 2000 {
t.Fatalf("expected extracted length to be 2000 runes, got %d", len([]rune(extracted)))
}
}
func TestAICopilotService_SuggestReply_ValidationAndErrors(t *testing.T) {
db := setupAICopilotTestDB(t)
ctx := context.Background()
svc := newAICopilotService()
// 1. Invalid conversation ID <= 0
_, err := svc.SuggestReply(ctx, 0, "en-US")
if err == nil {
t.Fatal("expected error for conversationID <= 0, got nil")
}
// 2. Conversation not found
_, err = svc.SuggestReply(ctx, 99999, "en-US")
if err == nil {
t.Fatal("expected error for non-existent conversation, got nil")
}
if i18nErr, ok := err.(*errorsx.I18nError); !ok || i18nErr.Key != "error.e0116" {
t.Fatalf("expected error.e0116 for not found conversation, got: %v", err)
}
// 3. Conversation exists but has no messages
conv := &models.Conversation{
ID: 101,
Subject: "Empty conversation",
}
if err := db.Create(conv).Error; err != nil {
t.Fatalf("failed to create conv: %v", err)
}
_, err = svc.SuggestReply(ctx, conv.ID, "en-US")
if err == nil {
t.Fatal("expected error when conversation has no messages, got nil")
}
// 4. Conversation has only recalled messages
recalledTime := time.Now()
msg := &models.Message{
ID: 1,
ConversationID: conv.ID,
ClientMsgID: "msg_1",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "recalled text",
RecalledAt: &recalledTime,
SendStatus: enums.IMMessageStatusRecalled,
}
if err := db.Create(msg).Error; err != nil {
t.Fatalf("failed to create msg: %v", err)
}
_, err = svc.SuggestReply(ctx, conv.ID, "en-US")
if err == nil {
t.Fatal("expected error when conversation has only recalled messages, got nil")
}
// 5. LLM chat failure
validMsg := &models.Message{
ID: 2,
ConversationID: conv.ID,
ClientMsgID: "msg_2",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "Can you help me reset my password?",
}
if err := db.Create(validMsg).Error; err != nil {
t.Fatalf("failed to create valid msg: %v", err)
}
svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
return nil, errors.New("upstream llm timeout")
}
_, err = svc.SuggestReply(ctx, conv.ID, "en-US")
if err == nil || !strings.Contains(err.Error(), "upstream llm timeout") {
t.Fatalf("expected llm error to propagate, got: %v", err)
}
}
func TestAICopilotService_SuggestReply_Success(t *testing.T) {
db := setupAICopilotTestDB(t)
ctx := context.Background()
conv := &models.Conversation{
ID: 201,
Subject: "Password Reset",
}
if err := db.Create(conv).Error; err != nil {
t.Fatalf("create conversation failed: %v", err)
}
msg := &models.Message{
ID: 10,
ConversationID: conv.ID,
ClientMsgID: "msg_10",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "I forgot my password, can you help?",
}
if err := db.Create(msg).Error; err != nil {
t.Fatalf("create message failed: %v", err)
}
var capturedSys, capturedUser string
svc := newAICopilotService()
svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
capturedSys = systemPrompt
capturedUser = userPrompt
return &ai.ChatCompletionResult{
Content: "To reset your password, please click the 'Forgot Password' link on the login page.",
ModelName: "gpt-4o",
}, nil
}
suggestion, err := svc.SuggestReply(ctx, conv.ID, "en-US")
if err != nil {
t.Fatalf("SuggestReply failed: %v", err)
}
expectedSuggestion := "To reset your password, please click the 'Forgot Password' link on the login page."
if suggestion != expectedSuggestion {
t.Fatalf("expected suggestion %q, got %q", expectedSuggestion, suggestion)
}
if !strings.Contains(capturedSys, "expert customer support co-pilot") {
t.Errorf("captured system prompt missing expected instructions: %s", capturedSys)
}
if !strings.Contains(capturedUser, "Customer: I forgot my password, can you help?") {
t.Errorf("captured user prompt missing message transcript: %s", capturedUser)
}
}
func TestAICopilotService_SummarizeConversation_Success(t *testing.T) {
db := setupAICopilotTestDB(t)
ctx := context.Background()
conv := &models.Conversation{
ID: 301,
Subject: "Billing Inquiry",
}
if err := db.Create(conv).Error; err != nil {
t.Fatalf("create conversation failed: %v", err)
}
messages := []models.Message{
{
ID: 30,
ConversationID: conv.ID,
ClientMsgID: "msg_30",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "Why was I charged twice for subscription?",
},
{
ID: 31,
ConversationID: conv.ID,
ClientMsgID: "msg_31",
SenderType: enums.IMSenderTypeAgent,
MessageType: enums.IMMessageTypeText,
Content: "I see the duplicate transaction and have initiated a full refund for the second charge.",
},
}
for _, m := range messages {
if err := db.Create(&m).Error; err != nil {
t.Fatalf("create message failed: %v", err)
}
}
var capturedSys, capturedUser string
svc := newAICopilotService()
svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
capturedSys = systemPrompt
capturedUser = userPrompt
return &ai.ChatCompletionResult{
Content: "1. Main Issue: Duplicate subscription charge.\n2. Actions taken: Initiated full refund for duplicate charge.\n3. Current Status: Resolved.",
ModelName: "gpt-4o",
}, nil
}
summary, err := svc.SummarizeConversation(ctx, conv.ID, "en-US")
if err != nil {
t.Fatalf("SummarizeConversation failed: %v", err)
}
if !strings.Contains(summary, "Duplicate subscription charge") {
t.Fatalf("unexpected summary: %s", summary)
}
if !strings.Contains(capturedSys, "customer support summarizer") {
t.Errorf("captured system prompt missing summarizer instructions: %s", capturedSys)
}
if !strings.Contains(capturedUser, "Customer: Why was I charged twice for subscription?") {
t.Errorf("captured user prompt missing transcript: %s", capturedUser)
}
}

View File

@@ -0,0 +1,5 @@
package services
import "agent-desk/internal/models"
var TriggerAIReplyAsyncHook func(conversation models.Conversation, message models.Message)

View File

@@ -0,0 +1,649 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"time"
"agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry"
workflowvalidator "agent-desk/internal/ai/workflow/validator"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
var AIWorkflowService = newAIWorkflowService()
func newAIWorkflowService() *aiWorkflowService {
return &aiWorkflowService{
registry: workflowregistry.DefaultRegistry(),
}
}
type aiWorkflowService struct {
registry *workflowregistry.Registry
}
type AIWorkflowRunAuditItem struct {
Run models.AIWorkflowRun
Workflow *models.AIWorkflow
Version *models.AIWorkflowVersion
Agent *models.AIAgent
}
type AIWorkflowTemplate struct {
Code string
Name string
Description string
Definition dsl.Definition
}
type AIWorkflowUsageItem struct {
Binding models.AIAgentWorkflowBinding
Agent *models.AIAgent
Version *models.AIWorkflowVersion
}
func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow {
if id <= 0 {
return nil
}
return repositories.AIWorkflowRepository.Get(sqls.DB(), id)
}
func (s *aiWorkflowService) GetVersion(id int64) *models.AIWorkflowVersion {
if id <= 0 {
return nil
}
return repositories.AIWorkflowVersionRepository.Get(sqls.DB(), id)
}
func (s *aiWorkflowService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflow, paging *sqls.Paging) {
return repositories.AIWorkflowRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *aiWorkflowService) FindVersionPageByParams(params *params.QueryParams) (list []models.AIWorkflowVersion, paging *sqls.Paging) {
return repositories.AIWorkflowVersionRepository.FindPageByParams(sqls.DB(), params)
}
func (s *aiWorkflowService) FindRunPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) {
return repositories.AIWorkflowRunRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *aiWorkflowService) BuildRunAuditItems(list []models.AIWorkflowRun) []AIWorkflowRunAuditItem {
ret := make([]AIWorkflowRunAuditItem, 0, len(list))
if len(list) == 0 {
return ret
}
workflowIDs := make([]int64, 0, len(list))
versionIDs := make([]int64, 0, len(list))
agentIDs := make([]int64, 0, len(list))
for _, item := range list {
workflowIDs = appendNonZeroInt64(workflowIDs, item.WorkflowID)
versionIDs = appendNonZeroInt64(versionIDs, item.WorkflowVersionID)
agentIDs = appendNonZeroInt64(agentIDs, item.AIAgentID)
}
var workflows []models.AIWorkflow
if len(workflowIDs) > 0 {
workflows = repositories.AIWorkflowRepository.Find(sqls.DB(), sqls.NewCnd().In("id", workflowIDs))
}
var versions []models.AIWorkflowVersion
if len(versionIDs) > 0 {
versions = repositories.AIWorkflowVersionRepository.Find(sqls.DB(), sqls.NewCnd().In("id", versionIDs))
}
var agents []models.AIAgent
if len(agentIDs) > 0 {
agents = repositories.AIAgentRepository.Find(sqls.DB(), sqls.NewCnd().In("id", agentIDs))
}
workflowByID := make(map[int64]*models.AIWorkflow, len(workflows))
for i := range workflows {
item := workflows[i]
workflowByID[item.ID] = &item
}
versionByID := make(map[int64]*models.AIWorkflowVersion, len(versions))
for i := range versions {
item := versions[i]
versionByID[item.ID] = &item
}
agentByID := make(map[int64]*models.AIAgent, len(agents))
for i := range agents {
item := agents[i]
agentByID[item.ID] = &item
}
for _, run := range list {
ret = append(ret, AIWorkflowRunAuditItem{
Run: run,
Workflow: workflowByID[run.WorkflowID],
Version: versionByID[run.WorkflowVersionID],
Agent: agentByID[run.AIAgentID],
})
}
return ret
}
func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []models.AIWorkflowNodeRun) {
if id <= 0 {
return nil, nil
}
run := repositories.AIWorkflowRunRepository.Get(sqls.DB(), id)
if run == nil {
return nil, nil
}
nodes := repositories.AIWorkflowNodeRunRepository.Find(sqls.DB(), sqls.NewCnd().Eq("workflow_run_id", id).Asc("id"))
return run, nodes
}
func appendNonZeroInt64(list []int64, value int64) []int64 {
if value <= 0 {
return list
}
for _, item := range list {
if item == value {
return list
}
}
return append(list, value)
}
func (s *aiWorkflowService) ListNodeSpecs() []workflowregistry.NodeSpec {
return s.registry.List()
}
func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition {
return defaultAgentWorkflowDefinition()
}
func (s *aiWorkflowService) ListWorkflowTemplates() []AIWorkflowTemplate {
return []AIWorkflowTemplate{
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationWorkflowDefinition()},
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationWorkflowDefinition()},
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationWorkflowDefinition()},
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationWorkflowDefinition()},
}
}
func ticketWithConfirmationWorkflowDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
workflowNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 600, 180, workflowInputs("issue", "start_1", "userMessage"), nil),
workflowNode("ready_route_1", workflowregistry.NodeTypeCondition, "草稿分流", 1020, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("ready", "草稿完整", "prompt_1", "draft_1", "ready", "is_true", nil),
{ID: "default", Name: "补充信息", TargetNodeID: "followup_1", Default: true},
}}),
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认", 1440, 100, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "ticketTitle": dsl.RefValue("draft_1", "title"), "ticketDescription": dsl.RefValue("draft_1", "description")}, map[string]any{"staticReply": "我已整理工单草稿:{{ticketTitle}}。请确认是否创建。"}),
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1860, 100, workflowInputs("prompt", "prompt_1", "replyText"), nil),
workflowNode("confirm_route_1", workflowregistry.NodeTypeCondition, "确认分流", 2280, 100, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "create_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消", TargetNodeID: "cancel_1", Default: true},
}}),
workflowNode("create_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2700, 20, map[string]dsl.Value{"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
workflowNode("followup_1", workflowregistry.NodeTypeLLMReply, "补充信息", 1440, 330, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions")}, map[string]any{"staticReply": "创建工单前还需要补充:{{followUpQuestions}}"}),
workflowNode("cancel_1", workflowregistry.NodeTypeLLMReply, "取消提示", 2700, 200, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。"}),
workflowNode("send_result_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 3120, 20, workflowInputs("replyText", "create_1", "message"), nil),
workflowNode("send_followup_1", workflowregistry.NodeTypeSendReply, "发送补充提示", 1860, 330, workflowInputs("replyText", "followup_1", "replyText"), nil),
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3120, 200, workflowInputs("replyText", "cancel_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3540, 180, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "draft_1"), workflowEdge("draft_1", "ready_route_1"), workflowPortEdge("ready_route_1", "prompt_1", "ready"), workflowPortEdge("ready_route_1", "followup_1", "default"),
workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "confirm_route_1"), workflowPortEdge("confirm_route_1", "create_1", "confirmed"), workflowPortEdge("confirm_route_1", "cancel_1", "default"),
workflowEdge("create_1", "send_result_1"), workflowEdge("send_result_1", "end_1"), workflowEdge("followup_1", "send_followup_1"), workflowEdge("send_followup_1", "end_1"), workflowEdge("cancel_1", "send_cancel_1"), workflowEdge("send_cancel_1", "end_1"),
},
}
}
func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result {
return workflowvalidator.ValidateDefinition(def, s.registry)
}
func (s *aiWorkflowService) CreateWorkflow(req request.CreateAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errorsx.InvalidParam("workflow name is required")
}
definition, err := marshalDefinition(req.Definition)
if err != nil {
return nil, err
}
item := &models.AIWorkflow{Name: name, Description: strings.TrimSpace(req.Description), Status: enums.StatusOk, DraftDefinition: definition, AuditFields: utils.BuildAuditFields(operator)}
if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil {
return nil, err
}
return item, nil
}
func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if s.Get(req.ID) == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return errorsx.InvalidParam("workflow name is required")
}
definition, err := marshalDefinition(req.Definition)
if err != nil {
return err
}
return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
"name": name,
"description": strings.TrimSpace(req.Description),
"draft_definition": definition,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if s.Get(id) == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
if repositories.AIAgentWorkflowBindingRepository.CountByWorkflowID(sqls.DB(), id) > 0 {
return errorsx.InvalidParam("workflow is still associated with an agent")
}
return repositories.AIWorkflowRepository.Updates(sqls.DB(), id, map[string]interface{}{
"status": enums.StatusDeleted,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}
func (s *aiWorkflowService) RestoreVersion(req request.RestoreAIWorkflowVersionRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
workflow := s.Get(req.WorkflowID)
version := s.GetVersion(req.WorkflowVersionID)
if workflow == nil || version == nil || version.WorkflowID != workflow.ID {
return errorsx.InvalidParamI18n("error.e0002")
}
return repositories.AIWorkflowRepository.Updates(sqls.DB(), workflow.ID, map[string]any{"draft_definition": version.Definition, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()})
}
func (s *aiWorkflowService) ListUsage(workflowID int64) []AIWorkflowUsageItem {
bindings := repositories.AIAgentWorkflowBindingRepository.FindByWorkflowID(sqls.DB(), workflowID)
ret := make([]AIWorkflowUsageItem, 0, len(bindings))
for _, binding := range bindings {
ret = append(ret, AIWorkflowUsageItem{Binding: binding, Agent: repositories.AIAgentRepository.Get(sqls.DB(), binding.AIAgentID), Version: repositories.AIWorkflowVersionRepository.Get(sqls.DB(), binding.WorkflowVersionID)})
}
return ret
}
func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflowVersion, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
workflow := s.Get(req.WorkflowID)
if workflow == nil || workflow.Status == enums.StatusDeleted {
return nil, errorsx.InvalidParamI18n("error.e0002")
}
result := s.ValidateDefinition(req.Definition)
if !result.Valid {
return nil, errorsx.InvalidParam("workflow definition is invalid")
}
definition, err := marshalDefinition(req.Definition)
if err != nil {
return nil, err
}
now := time.Now()
var version *models.AIWorkflowVersion
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
nextVersion := repositories.AIWorkflowVersionRepository.MaxVersionByWorkflowID(ctx.Tx, req.WorkflowID) + 1
version = &models.AIWorkflowVersion{
WorkflowID: req.WorkflowID,
Version: nextVersion,
Status: enums.StatusOk,
Definition: definition,
DefinitionHash: hashDefinition(definition),
PublishedAt: &now,
PublishedByID: operator.UserID,
PublishedByName: operator.Username,
AuditFields: utils.BuildAuditFields(operator),
}
if err := repositories.AIWorkflowVersionRepository.Create(ctx.Tx, version); err != nil {
return err
}
return repositories.AIWorkflowRepository.Updates(ctx.Tx, req.WorkflowID, map[string]interface{}{
"draft_definition": definition,
"published_version_id": version.ID,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": now,
})
})
if err != nil {
return nil, err
}
return version, nil
}
func defaultAgentWorkflowDefinition() dsl.Definition {
return officialDefaultAgentWorkflowDefinition()
}
func officialDefaultAgentWorkflowDefinition() dsl.Definition {
return dsl.Definition{
Nodes: []dsl.Node{
{
ID: "start_0",
Type: workflowregistry.NodeTypeStart,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 180, Y: 300}},
Data: dsl.NodeData{
Title: "Start",
Outputs: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","default":"Hello Flow."}}}`),
},
},
{
ID: "llm_0",
Type: workflowregistry.NodeTypeLLM,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 640, Y: 220}},
Data: dsl.NodeData{
Title: "LLM",
InputsValues: map[string]dsl.Value{
"modelName": dsl.ConstantValue("gpt-3.5-turbo"),
"apiKey": dsl.ConstantValue("sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"),
"apiHost": dsl.ConstantValue("https://mock-ai-url/api/v3"),
"temperature": dsl.ConstantValue(0.5),
"systemPrompt": dsl.TemplateValue(
"# Role\nYou are an AI assistant.\n",
),
"prompt": dsl.TemplateValue(""),
},
Inputs: json.RawMessage(`{"type":"object","required":["modelName","apiKey","apiHost","temperature","prompt"],"properties":{"modelName":{"type":"string"},"apiKey":{"type":"string"},"apiHost":{"type":"string"},"temperature":{"type":"number"},"systemPrompt":{"type":"string","extra":{"formComponent":"prompt-editor"}},"prompt":{"type":"string","extra":{"formComponent":"prompt-editor"}}}}`),
Outputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
},
},
{
ID: "end_0",
Type: workflowregistry.NodeTypeEnd,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 1100, Y: 300}},
Data: dsl.NodeData{
Title: "End",
InputsValues: map[string]dsl.Value{
"result": dsl.RefValue("llm_0", "result"),
},
Inputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
},
},
},
Edges: []dsl.Edge{
{SourceNodeID: "start_0", TargetNodeID: "llm_0"},
{SourceNodeID: "llm_0", TargetNodeID: "end_0"},
},
GlobalVariable: json.RawMessage(`{"type":"object","properties":{}}`),
}
}
func legacyDefaultAgentWorkflowDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 285.5, nil, nil),
workflowNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "会话理解", 640, 285.5, workflowInputs("userMessage", "start_1", "userMessage"), nil),
workflowNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "回复策略", 1100, 285.5, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
}, nil),
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("handoff", "转人工", "handoff_confirm_prompt_1", "policy_1", "action", "eq", "handoff_to_human"),
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
workflowConditionBranch("ticket", "创建工单", "draft_ticket_1", "policy_1", "action", "eq", "prepare_ticket"),
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
{ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true},
}}),
workflowNode("handoff_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "转人工确认文案", 2020, 0, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我可以为你转接人工客服处理。请回复“确认”继续转人工,或回复“取消”继续由 AI 协助。"}),
workflowNode("handoff_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认转人工", 2480, 0, workflowInputs("prompt", "handoff_confirm_prompt_1", "replyText"), nil),
workflowNode("handoff_confirm_route_1", workflowregistry.NodeTypeCondition, "转人工确认分流", 2940, 0, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "handoff_1", "handoff_confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消或未确认", TargetNodeID: "handoff_cancel_reply_1", Default: true},
}}),
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 3400, 0, map[string]dsl.Value{
"reason": dsl.RefValue("start_1", "userMessage"),
"confirmed": dsl.RefValue("handoff_confirm_1", "confirmed"),
}, nil),
workflowNode("handoff_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消转人工提示", 3400, 480, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消转人工。你可以继续补充问题,我会继续协助。"}),
workflowNode("send_handoff_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3860, 480, workflowInputs("replyText", "handoff_cancel_reply_1", "replyText"), nil),
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil),
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 3860, 0, nil, nil),
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil),
workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil),
{ID: "default", Name: "补充信息", TargetNodeID: "ticket_followup_reply_1", Default: true},
}}),
workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 2940, 285.5, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"ticketTitle": dsl.RefValue("draft_ticket_1", "title"),
"ticketDescription": dsl.RefValue("draft_ticket_1", "description"),
}, map[string]any{"staticReply": "我已整理工单草稿,请确认是否创建:\n标题:{{ticketTitle}}\n描述:{{ticketDescription}}\n请回复“确认”创建工单,或回复“取消”放弃。"}),
workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 3400, 285.5, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil),
workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 3860, 235.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "create_ticket_1", "ticket_confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true},
}}),
workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 4780, 192, map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_ticket_1", "ticketDraft"),
"confirmed": dsl.RefValue("ticket_confirm_1", "confirmed"),
}, nil),
workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 5240, 192, workflowInputs("replyText", "create_ticket_1", "message"), nil),
workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 4320, 379, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}),
workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 4780, 379, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil),
workflowNode("ticket_followup_reply_1", workflowregistry.NodeTypeLLMReply, "追问工单信息", 3860, 1033.5, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"followUpQuestions": dsl.RefValue("draft_ticket_1", "followUpQuestions"),
}, map[string]any{"staticReply": "为了创建工单,还需要补充以下信息:\n{{followUpQuestions}}"}),
workflowNode("send_ticket_followup_1", workflowregistry.NodeTypeSendReply, "发送工单追问", 4780, 1033.5, workflowInputs("replyText", "ticket_followup_reply_1", "replyText"), nil),
workflowNode("retrieve_1", workflowregistry.NodeTypeKnowledgeRetrieve, "知识检索", 2480, 753, workflowInputs("query", "start_1", "userMessage"), map[string]any{"knowledgeBaseIds": []int64{}}),
workflowNode("answerability_1", workflowregistry.NodeTypeAnswerabilityGate, "可回答判断", 2940, 753, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
workflowNode("answerability_route_1", workflowregistry.NodeTypeCondition, "可回答分流", 3400, 703, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("answerable", "可以回答", "reply_1", "answerability_1", "answerability", "eq", "answerable"),
{ID: "default", Name: "兜底追问", TargetNodeID: "fallback_reply_1", Default: true},
}}),
workflowNode("reply_1", workflowregistry.NodeTypeLLMReply, "AI 回复", 3860, 659.5, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
workflowNode("send_1", workflowregistry.NodeTypeSendReply, "发送回复", 4320, 659.5, workflowInputs("replyText", "reply_1", "replyText"), nil),
workflowNode("fallback_reply_1", workflowregistry.NodeTypeLLMReply, "兜底追问", 3860, 846.5, map[string]dsl.Value{
"userMessage": dsl.RefValue("start_1", "userMessage"),
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
}, nil),
workflowNode("send_fallback_1", workflowregistry.NodeTypeSendReply, "发送兜底", 4320, 846.5, workflowInputs("replyText", "fallback_reply_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 5700, 472.5, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "understanding_1"),
workflowEdge("understanding_1", "policy_1"),
workflowEdge("policy_1", "policy_route_1"),
workflowPortEdge("policy_route_1", "handoff_confirm_prompt_1", "handoff"),
workflowPortEdge("policy_route_1", "policy_reply_1", "direct"),
workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"),
workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"),
workflowPortEdge("policy_route_1", "draft_ticket_1", "ticket"),
workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"),
workflowPortEdge("policy_route_1", "policy_reply_1", "default"),
workflowEdge("policy_reply_1", "end_1"),
workflowEdge("handoff_confirm_prompt_1", "handoff_confirm_1"),
workflowEdge("handoff_confirm_1", "handoff_confirm_route_1"),
workflowPortEdge("handoff_confirm_route_1", "handoff_1", "confirmed"),
workflowPortEdge("handoff_confirm_route_1", "handoff_cancel_reply_1", "default"),
workflowEdge("handoff_1", "handoff_end_1"),
workflowEdge("handoff_cancel_reply_1", "send_handoff_cancel_1"),
workflowEdge("send_handoff_cancel_1", "end_1"),
workflowEdge("draft_ticket_1", "ticket_draft_route_1"),
workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"),
workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"),
workflowEdge("ticket_confirm_prompt_1", "ticket_confirm_1"),
workflowEdge("ticket_confirm_1", "ticket_confirm_route_1"),
workflowPortEdge("ticket_confirm_route_1", "create_ticket_1", "confirmed"),
workflowPortEdge("ticket_confirm_route_1", "ticket_cancel_reply_1", "default"),
workflowEdge("create_ticket_1", "ticket_result_reply_1"),
workflowEdge("ticket_result_reply_1", "end_1"),
workflowEdge("ticket_cancel_reply_1", "send_ticket_cancel_1"),
workflowEdge("send_ticket_cancel_1", "end_1"),
workflowEdge("ticket_followup_reply_1", "send_ticket_followup_1"),
workflowEdge("send_ticket_followup_1", "end_1"),
workflowEdge("retrieve_1", "answerability_1"),
workflowEdge("answerability_1", "answerability_route_1"),
workflowPortEdge("answerability_route_1", "reply_1", "answerable"),
workflowPortEdge("answerability_route_1", "fallback_reply_1", "default"),
workflowEdge("reply_1", "send_1"),
workflowEdge("send_1", "end_1"),
workflowEdge("fallback_reply_1", "send_fallback_1"),
workflowEdge("send_fallback_1", "end_1"),
},
}
}
func identityConfirmationWorkflowDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "身份确认提示", 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "为保护你的账户信息,请确认是否继续身份核验。"}),
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认身份核验", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "confirmed_reply_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
}}),
workflowNode("confirmed_reply_1", workflowregistry.NodeTypeLLMReply, "确认结果", 1860, 100, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已收到确认,人工客服将继续为你核验身份。"}),
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消身份核验。"}),
workflowNode("send_confirmed_1", workflowregistry.NodeTypeSendReply, "发送确认结果", 2280, 100, workflowInputs("replyText", "confirmed_reply_1", "replyText"), nil),
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
workflowPortEdge("route_1", "confirmed_reply_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
workflowEdge("confirmed_reply_1", "send_confirmed_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_confirmed_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
},
}
}
func complaintEscalationWorkflowDefinition() dsl.Definition {
return confirmationHandoffWorkflowDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
}
func confirmationHandoffWorkflowDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, title, 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": prompt}),
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认升级", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "handoff_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
}}),
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工处理", 1860, 100, map[string]dsl.Value{"reason": dsl.RefValue("start_1", "userMessage"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": cancelledReply}),
workflowNode("send_handoff_1", workflowregistry.NodeTypeSendReply, "发送升级结果", 2280, 100, workflowInputs("replyText", "handoff_1", "message"), nil),
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
workflowPortEdge("route_1", "handoff_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
workflowEdge("handoff_1", "send_handoff_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_handoff_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
},
}
}
func refundRequestPreparationWorkflowDefinition() dsl.Definition {
return confirmationHandoffWorkflowDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
}
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: x, Y: y}},
Data: dsl.NodeData{
Title: title,
Config: mustMarshalWorkflowConfig(config),
InputsValues: inputs,
},
}
}
func workflowInputs(name string, nodeID string, field string) map[string]dsl.Value {
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
}
func workflowConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
return dsl.ConditionBranch{
ID: id,
Name: name,
TargetNodeID: targetNodeID,
Condition: &dsl.Condition{
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
Operator: operator,
Right: right,
},
}
}
func workflowEdge(source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
}
func workflowPortEdge(source string, target string, sourcePortID string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: sourcePortID}
}
func mustMarshalWorkflowConfig(value any) json.RawMessage {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func defaultAgentWorkflowName(agentName string) string {
agentName = strings.TrimSpace(agentName)
if agentName == "" {
return "会话流程"
}
return agentName + " 会话流程"
}
func marshalDefinition(def dsl.Definition) (string, error) {
buf, err := json.Marshal(def)
if err != nil {
return "", errorsx.InvalidParam("invalid workflow definition")
}
return string(buf), nil
}
func hashDefinition(definition string) string {
sum := sha256.Sum256([]byte(definition))
return hex.EncodeToString(sum[:])
}