// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package services import ( "crypto/sha256" "encoding/json" "errors" "fmt" "log/slog" "reflect" "slices" "strconv" "strings" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/utils" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/clause" ) var SetupReadinessService = newSetupReadinessService() func newSetupReadinessService() *setupReadinessService { return &setupReadinessService{} } type setupReadinessService struct{} const ( QuickFixActionQuickSetupAll = "quick_setup_all" QuickFixActionEnsureTeamSchedule = "ensure_team_and_schedule" QuickFixActionEnableMyProfile = "enable_my_operator_profile" QuickFixActionFixAIAgentHandoff = "fix_ai_agent_handoff" QuickFixActionEnsureDefaultChannel = "ensure_default_channel" ) func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*response.SetupReadinessResponse, error) { now := time.Now() db := sqls.DB() var teams []models.AgentTeam if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&teams).Error; err != nil { return nil, err } activeTeamMap := make(map[int64]models.AgentTeam) activeTeamIDs := make([]int64, 0, len(teams)) for _, team := range teams { if team.Status == enums.StatusOk { activeTeamMap[team.ID] = team activeTeamIDs = append(activeTeamIDs, team.ID) } } var schedules []models.AgentTeamSchedule if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&schedules).Error; err != nil { return nil, err } activeScheduleNowTeamSet := make(map[int64]bool) anyScheduleTeamSet := make(map[int64]bool) for _, sch := range schedules { anyScheduleTeamSet[sch.TeamID] = true if sch.Status != enums.StatusOk { continue } if !sch.StartAt.After(now) && sch.EndAt.After(now) { activeScheduleNowTeamSet[sch.TeamID] = true } } var profiles []models.AgentProfile if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&profiles).Error; err != nil { return nil, err } var users []models.User if err := db.Where("id IN ?", extractProfileUserIDs(profiles)).Find(&users).Error; err != nil { return nil, err } activeUserMap := make(map[int64]models.User, len(users)) for _, u := range users { if u.Status == enums.StatusOk && u.DeletedAt == nil { activeUserMap[u.ID] = u } } activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(profiles)) if err != nil { return nil, err } var aiAgents []models.AIAgent if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&aiAgents).Error; err != nil { return nil, err } var channels []models.Channel if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&channels).Error; err != nil { return nil, err } // Step 1: Operator Teams step1 := s.evaluateTeamsStep(teams, activeTeamIDs) // Step 2: Shift Schedules (Active Now) step2 := s.evaluateSchedulesStep(teams, activeTeamMap, activeScheduleNowTeamSet, anyScheduleTeamSet) // Step 3: Operator Profiles step3, readyOperatorTeams := s.evaluateOperatorsStep(profiles, activeTeamMap, activeScheduleNowTeamSet, activeUserMap, activeCounts) // Step 4: AI Agents & Handoff Configuration step4, readyAIAgentMap := s.evaluateAIAgentsStep(aiAgents, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams) // Step 5: Inbound Channels step5, channelBriefs := s.evaluateChannelsStep(channels, aiAgents) // Step 6: End-to-End Routing Path step6, canReceiveRequestsNow := s.evaluateEndToEndStep(channels, aiAgents, readyAIAgentMap, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams) steps := []response.SetupStepItem{step1, step2, step3, step4, step5, step6} for i := range steps { steps[i] = s.filterStepQuickFixes(steps[i], operator) } readySteps := 0 hasError := false hasWarning := false for _, st := range steps { switch st.Status { case "ok": readySteps++ case "error": hasError = true case "warning": hasWarning = true } } overallStatus := "ok" if hasError || !canReceiveRequestsNow { if hasError { overallStatus = "error" } else { overallStatus = "warning" } } else if hasWarning { overallStatus = "warning" } currentOp := s.evaluateCurrentOperator(operator, profiles, activeTeamMap, activeScheduleNowTeamSet, activeCounts) return &response.SetupReadinessResponse{ OverallStatus: overallStatus, ReadySteps: readySteps, TotalSteps: len(steps), CanReceiveRequestsNow: canReceiveRequestsNow, Steps: steps, CurrentOperator: currentOp, Channels: channelBriefs, }, nil } func (s *setupReadinessService) evaluateTeamsStep(teams []models.AgentTeam, activeTeamIDs []int64) response.SetupStepItem { issues := make([]response.SetupIssueItem, 0) quickFixes := make([]string, 0) status := "ok" summaryCode := "ok" if len(activeTeamIDs) == 0 { status = "error" summaryCode = "no_active_team" issues = append(issues, response.SetupIssueItem{ Code: "no_active_team", Severity: "error", EntityType: "team", QuickFixAction: QuickFixActionEnsureTeamSchedule, }) quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) } return response.SetupStepItem{ Key: "teams", StepNumber: 1, Status: status, SummaryCode: summaryCode, TotalCount: len(teams), ReadyCount: len(activeTeamIDs), Issues: issues, QuickFixActions: quickFixes, ManagePath: "/dashboard/agents", } } func (s *setupReadinessService) evaluateSchedulesStep( teams []models.AgentTeam, activeTeamMap map[int64]models.AgentTeam, activeScheduleNowTeamSet map[int64]bool, anyScheduleTeamSet map[int64]bool, ) response.SetupStepItem { issues := make([]response.SetupIssueItem, 0) quickFixes := make([]string, 0) readyTeamCount := 0 for _, team := range teams { if team.Status != enums.StatusOk { continue } if activeScheduleNowTeamSet[team.ID] { readyTeamCount++ continue } issueCode := "team_no_schedule_configured" if anyScheduleTeamSet[team.ID] { issueCode = "team_off_hours_now" } issues = append(issues, response.SetupIssueItem{ Code: issueCode, Severity: "warning", EntityType: "team", EntityID: team.ID, EntityName: team.Name, }) } status := "ok" summaryCode := "ok" if len(activeTeamMap) == 0 { status = "error" summaryCode = "no_active_team" } else if readyTeamCount == 0 { status = "error" summaryCode = "no_schedule_now" } else if len(issues) > 0 { status = "warning" summaryCode = "partial_schedule_now" } return response.SetupStepItem{ Key: "schedules", StepNumber: 2, Status: status, SummaryCode: summaryCode, TotalCount: len(activeTeamMap), ReadyCount: readyTeamCount, Issues: issues, QuickFixActions: quickFixes, ManagePath: "/dashboard/agent-team-schedules", } } func (s *setupReadinessService) evaluateOperatorsStep( profiles []models.AgentProfile, activeTeamMap map[int64]models.AgentTeam, activeScheduleNowTeamSet map[int64]bool, activeUserMap map[int64]models.User, activeCounts map[int64]int, ) (response.SetupStepItem, map[int64]bool) { issues := make([]response.SetupIssueItem, 0) quickFixes := make([]string, 0) readyCount := 0 readyOperatorTeams := make(map[int64]bool) for _, p := range profiles { displayName := strings.TrimSpace(p.DisplayName) if displayName == "" { if u, ok := activeUserMap[p.UserID]; ok { displayName = firstNonEmpty(u.Nickname, u.Username) } } if displayName == "" { displayName = fmt.Sprintf("#%d", p.UserID) } if p.Status != enums.StatusOk { issues = append(issues, response.SetupIssueItem{ Code: "operator_profile_disabled", Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName, QuickFixAction: QuickFixActionEnableMyProfile, }) continue } if _, userOk := activeUserMap[p.UserID]; !userOk { issues = append(issues, response.SetupIssueItem{ Code: "operator_user_disabled", Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName, }) continue } if _, teamOk := activeTeamMap[p.TeamID]; !teamOk { issues = append(issues, response.SetupIssueItem{ Code: "operator_no_active_team", Severity: "error", EntityType: "operator", EntityID: p.ID, EntityName: displayName, QuickFixAction: QuickFixActionEnableMyProfile, }) continue } if !p.AutoAssignEnabled { issues = append(issues, response.SetupIssueItem{ Code: "operator_auto_assign_off", Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName, QuickFixAction: QuickFixActionEnableMyProfile, }) continue } if p.ServiceStatus != enums.ServiceStatusIdle { issues = append(issues, response.SetupIssueItem{ Code: "operator_busy", Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName, QuickFixAction: QuickFixActionEnableMyProfile, }) continue } activeCount := activeCounts[p.UserID] if p.MaxConcurrentCount <= 0 || activeCount >= p.MaxConcurrentCount { issues = append(issues, response.SetupIssueItem{ Code: "operator_at_capacity", Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName, QuickFixAction: QuickFixActionEnableMyProfile, }) continue } readyCount++ if activeScheduleNowTeamSet[p.TeamID] { readyOperatorTeams[p.TeamID] = true } } status := "ok" summaryCode := "ok" if len(profiles) == 0 { status = "error" summaryCode = "no_operator_profile" issues = append(issues, response.SetupIssueItem{ Code: "no_operator_profile", Severity: "error", EntityType: "operator", QuickFixAction: QuickFixActionEnableMyProfile, }) quickFixes = append(quickFixes, QuickFixActionEnableMyProfile) } else if readyCount == 0 { status = "error" summaryCode = "no_idle_operator" quickFixes = append(quickFixes, QuickFixActionEnableMyProfile) } else if len(issues) > 0 { status = "warning" summaryCode = "some_operators_unavailable" } return response.SetupStepItem{ Key: "operators", StepNumber: 3, Status: status, SummaryCode: summaryCode, TotalCount: len(profiles), ReadyCount: readyCount, Issues: issues, QuickFixActions: quickFixes, ManagePath: "/dashboard/agents", }, readyOperatorTeams } func (s *setupReadinessService) evaluateAIAgentsStep( aiAgents []models.AIAgent, activeTeamMap map[int64]models.AgentTeam, activeScheduleNowTeamSet map[int64]bool, readyOperatorTeams map[int64]bool, ) (response.SetupStepItem, map[int64]bool) { issues := make([]response.SetupIssueItem, 0) quickFixes := make([]string, 0) readyCount := 0 readyAIAgentMap := make(map[int64]bool) activeAgentCount := 0 for _, agent := range aiAgents { if agent.Status != enums.StatusOk { continue } activeAgentCount++ if agent.PublishedRevisionID <= 0 { issues = append(issues, response.SetupIssueItem{Code: "agent_unpublished", Severity: "error", EntityType: "ai_agent", EntityID: agent.ID, EntityName: agent.Name}) continue } if agent.ServiceMode == enums.IMConversationServiceModeAIOnly { issues = append(issues, response.SetupIssueItem{ Code: "agent_ai_only_mode", Severity: "warning", EntityType: "ai_agent", EntityID: agent.ID, EntityName: agent.Name, QuickFixAction: QuickFixActionFixAIAgentHandoff, }) continue } teamIDs := utils.SplitInt64s(agent.TeamIDs) validTeamIDs := make([]int64, 0, len(teamIDs)) for _, tid := range teamIDs { if _, ok := activeTeamMap[tid]; ok { validTeamIDs = append(validTeamIDs, tid) } } if len(validTeamIDs) == 0 { issues = append(issues, response.SetupIssueItem{ Code: "agent_no_teams", Severity: "error", EntityType: "ai_agent", EntityID: agent.ID, EntityName: agent.Name, QuickFixAction: QuickFixActionFixAIAgentHandoff, }) continue } if agent.ServiceMode == enums.IMConversationServiceModeAIFirst && !agent.AutoHandoffOnFailure { issues = append(issues, response.SetupIssueItem{ Code: "agent_auto_handoff_disabled", Severity: "warning", EntityType: "ai_agent", EntityID: agent.ID, EntityName: agent.Name, QuickFixAction: QuickFixActionFixAIAgentHandoff, }) } readyCount++ for _, tid := range validTeamIDs { if activeScheduleNowTeamSet[tid] && readyOperatorTeams[tid] { readyAIAgentMap[agent.ID] = true break } } } status := "ok" summaryCode := "ok" if activeAgentCount == 0 { status = "error" summaryCode = "no_active_ai_agent" issues = append(issues, response.SetupIssueItem{ Code: "no_active_ai_agent", Severity: "error", EntityType: "ai_agent", QuickFixAction: QuickFixActionFixAIAgentHandoff, }) quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff) } else if readyCount == 0 { status = "error" summaryCode = "no_handoff_ready_agent" quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff) } else if len(issues) > 0 { status = "warning" summaryCode = "agent_has_warnings" quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff) } return response.SetupStepItem{ Key: "ai_agents", StepNumber: 4, Status: status, SummaryCode: summaryCode, TotalCount: len(aiAgents), ReadyCount: readyCount, Issues: issues, QuickFixActions: quickFixes, ManagePath: "/dashboard/ai-agents", }, readyAIAgentMap } func (s *setupReadinessService) evaluateChannelsStep( channels []models.Channel, aiAgents []models.AIAgent, ) (response.SetupStepItem, []response.SetupChannelBrief) { issues := make([]response.SetupIssueItem, 0) quickFixes := make([]string, 0) readyCount := 0 agentMap := make(map[int64]models.AIAgent, len(aiAgents)) for _, a := range aiAgents { if a.Status == enums.StatusOk { agentMap[a.ID] = a } } briefs := make([]response.SetupChannelBrief, 0, len(channels)) activeChannelCount := 0 for _, ch := range channels { briefs = append(briefs, response.SetupChannelBrief{ ID: ch.ID, Name: ch.Name, ChannelType: ch.ChannelType, ChannelID: ch.ChannelID, AIAgentID: ch.AIAgentID, RolloutPercent: ch.AIAgentRolloutPercent, Status: int(ch.Status), }) if ch.Status != enums.StatusOk { continue } activeChannelCount++ if ch.AIAgentID <= 0 { issues = append(issues, response.SetupIssueItem{ Code: "channel_no_agent", Severity: "error", EntityType: "channel", EntityID: ch.ID, EntityName: ch.Name, QuickFixAction: QuickFixActionEnsureDefaultChannel, }) continue } if _, ok := agentMap[ch.AIAgentID]; !ok { issues = append(issues, response.SetupIssueItem{ Code: "channel_agent_disabled", Severity: "error", EntityType: "channel", EntityID: ch.ID, EntityName: ch.Name, QuickFixAction: QuickFixActionEnsureDefaultChannel, }) continue } if agentMap[ch.AIAgentID].PublishedRevisionID <= 0 { issues = append(issues, response.SetupIssueItem{Code: "channel_agent_unpublished", Severity: "error", EntityType: "channel", EntityID: ch.ID, EntityName: ch.Name}) continue } if ch.AIAgentRolloutPercent <= 0 { issues = append(issues, response.SetupIssueItem{ Code: "channel_zero_rollout", Severity: "error", EntityType: "channel", EntityID: ch.ID, EntityName: ch.Name, QuickFixAction: QuickFixActionEnsureDefaultChannel, }) continue } readyCount++ } status := "ok" summaryCode := "ok" if activeChannelCount == 0 { status = "error" summaryCode = "no_active_channel" issues = append(issues, response.SetupIssueItem{ Code: "no_active_channel", Severity: "error", EntityType: "channel", QuickFixAction: QuickFixActionEnsureDefaultChannel, }) quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel) } else if readyCount == 0 { status = "error" summaryCode = "no_ready_channel" quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel) } else if len(issues) > 0 { status = "warning" summaryCode = "some_channels_misconfigured" quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel) } return response.SetupStepItem{ Key: "channels", StepNumber: 5, Status: status, SummaryCode: summaryCode, TotalCount: len(channels), ReadyCount: readyCount, Issues: issues, QuickFixActions: quickFixes, ManagePath: "/dashboard/channels", }, briefs } func (s *setupReadinessService) evaluateEndToEndStep( channels []models.Channel, aiAgents []models.AIAgent, readyAIAgentMap map[int64]bool, activeTeamMap map[int64]models.AgentTeam, activeScheduleNowTeamSet map[int64]bool, readyOperatorTeams map[int64]bool, ) (response.SetupStepItem, bool) { issues := make([]response.SetupIssueItem, 0) quickFixes := make([]string, 0) readyChannelCount := 0 activeChannelCount := 0 for _, ch := range channels { if ch.Status != enums.StatusOk { continue } activeChannelCount++ if ch.AIAgentRolloutPercent > 0 && readyAIAgentMap[ch.AIAgentID] { readyChannelCount++ } } canReceiveNow := readyChannelCount > 0 status := "ok" summaryCode := "ok" if !canReceiveNow { status = "error" summaryCode = "no_end_to_end_path" issues = append(issues, response.SetupIssueItem{ Code: "no_end_to_end_path", Severity: "error", EntityType: "system", QuickFixAction: QuickFixActionQuickSetupAll, }) quickFixes = append(quickFixes, QuickFixActionQuickSetupAll) } _ = aiAgents _ = activeTeamMap _ = activeScheduleNowTeamSet _ = readyOperatorTeams return response.SetupStepItem{ Key: "end_to_end", StepNumber: 6, Status: status, SummaryCode: summaryCode, TotalCount: max(activeChannelCount, 1), ReadyCount: readyChannelCount, Issues: issues, QuickFixActions: quickFixes, ManagePath: "/dashboard/setup", }, canReceiveNow } func (s *setupReadinessService) evaluateCurrentOperator( operator *dto.AuthPrincipal, profiles []models.AgentProfile, activeTeamMap map[int64]models.AgentTeam, activeScheduleNowTeamSet map[int64]bool, activeCounts map[int64]int, ) response.CurrentOperatorDiagnostic { if operator == nil || operator.UserID <= 0 { return response.CurrentOperatorDiagnostic{ BlockerCodes: []string{"not_authenticated"}, } } diag := response.CurrentOperatorDiagnostic{ UserID: operator.UserID, Username: operator.Username, Nickname: operator.Nickname, BlockerCodes: make([]string, 0), } var matched *models.AgentProfile for i := range profiles { if profiles[i].UserID == operator.UserID { matched = &profiles[i] break } } if matched == nil { diag.BlockerCodes = append(diag.BlockerCodes, "missing_profile") return diag } diag.HasProfile = true diag.ProfileStatusOk = matched.Status == enums.StatusOk diag.TeamID = matched.TeamID diag.ServiceStatus = int(matched.ServiceStatus) diag.AutoAssignEnabled = matched.AutoAssignEnabled diag.ActiveConversationCount = int64(activeCounts[operator.UserID]) diag.MaxConcurrentCount = matched.MaxConcurrentCount if !diag.ProfileStatusOk { diag.BlockerCodes = append(diag.BlockerCodes, "profile_disabled") } if team, ok := activeTeamMap[matched.TeamID]; ok { diag.TeamName = team.Name diag.TeamActive = true diag.TeamHasScheduleNow = activeScheduleNowTeamSet[team.ID] if !diag.TeamHasScheduleNow { diag.BlockerCodes = append(diag.BlockerCodes, "team_off_hours") } } else { diag.BlockerCodes = append(diag.BlockerCodes, "no_team") } if !matched.AutoAssignEnabled { diag.BlockerCodes = append(diag.BlockerCodes, "auto_assign_disabled") } if matched.ServiceStatus != enums.ServiceStatusIdle { diag.BlockerCodes = append(diag.BlockerCodes, "status_busy") } if matched.MaxConcurrentCount <= 0 || int(diag.ActiveConversationCount) >= matched.MaxConcurrentCount { diag.BlockerCodes = append(diag.BlockerCodes, "at_capacity") } diag.CanReceiveNow = len(diag.BlockerCodes) == 0 return diag } func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequest, operator *dto.AuthPrincipal) (*response.SetupSimulateRouteResponse, error) { if req.BypassAI { return nil, errorsx.InvalidParamI18n("error.setup.bypassUnsupported") } now := time.Now() db := sqls.DB() fixIfAllowed := func(action string) string { if s.canRunQuickFixAction(operator, action) { return action } return "" } var channel models.Channel if req.ChannelID > 0 { if err := db.Where("status <> ?", enums.StatusDeleted).First(&channel, req.ChannelID).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err) } return &response.SetupSimulateRouteResponse{ CandidateOperators: []response.SimulatedOperatorCandidate{}, WillReachOperator: false, FinalOutcomeCode: "blocked_no_channel", Hops: []response.SimulatedRouteHop{ { HopKey: "channel", Status: "error", ReasonCode: "blocked_no_channel", QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), }, }, }, nil } } else { if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&channel).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err) } if err2 := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").First(&channel).Error; err2 != nil { if !errors.Is(err2, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err2) } return &response.SetupSimulateRouteResponse{ CandidateOperators: []response.SimulatedOperatorCandidate{}, WillReachOperator: false, FinalOutcomeCode: "blocked_no_channel", Hops: []response.SimulatedRouteHop{ { HopKey: "channel", Status: "error", ReasonCode: "blocked_no_channel", QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), }, }, }, nil } } } resp := &response.SetupSimulateRouteResponse{ ChannelID: channel.ID, ChannelName: channel.Name, ChannelType: channel.ChannelType, Hops: make([]response.SimulatedRouteHop, 0, 5), CandidateOperators: make([]response.SimulatedOperatorCandidate, 0), } // Hop 1: Channel if channel.Status != enums.StatusOk { resp.FinalOutcomeCode = "blocked_channel_disabled" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "channel", Status: "error", ReasonCode: "blocked_channel_disabled", EntityID: channel.ID, EntityName: channel.Name, QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), }) return resp, nil } if channel.AIAgentRolloutPercent <= 0 { resp.FinalOutcomeCode = "blocked_zero_rollout" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "channel", Status: "error", ReasonCode: "blocked_zero_rollout", EntityID: channel.ID, EntityName: channel.Name, QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), }) return resp, nil } if channel.AIAgentRolloutPercent < 100 { resp.FinalOutcomeCode = "partial_rollout_not_simulated" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "channel", Status: "warning", ReasonCode: resp.FinalOutcomeCode, EntityID: channel.ID, EntityName: channel.Name}) return resp, nil } if channel.AIAgentID <= 0 { resp.FinalOutcomeCode = "blocked_missing_ai_agent" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "channel", Status: "error", ReasonCode: "blocked_missing_ai_agent", EntityID: channel.ID, EntityName: channel.Name, QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel), }) return resp, nil } resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "channel", Status: "ok", ReasonCode: "ok", EntityID: channel.ID, EntityName: channel.Name, }) // Hop 2: AI Agent var aiAgent models.AIAgent agentErr := db.First(&aiAgent, channel.AIAgentID).Error if agentErr != nil && !errors.Is(agentErr, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(agentErr) } if agentErr != nil || aiAgent.Status != enums.StatusOk { resp.FinalOutcomeCode = "blocked_missing_ai_agent" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "ai_agent", Status: "error", ReasonCode: "blocked_missing_ai_agent", EntityID: channel.AIAgentID, QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), }) return resp, nil } if aiAgent.PublishedRevisionID <= 0 { resp.FinalOutcomeCode = "blocked_agent_unpublished" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "error", ReasonCode: resp.FinalOutcomeCode, EntityID: aiAgent.ID, EntityName: aiAgent.Name}) return resp, nil } if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly { resp.FinalOutcomeCode = "blocked_ai_only_mode" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "ai_agent", Status: "error", ReasonCode: "blocked_ai_only_mode", EntityID: aiAgent.ID, EntityName: aiAgent.Name, QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), }) return resp, nil } if aiAgent.ServiceMode != enums.IMConversationServiceModeHumanOnly { resp.FinalOutcomeCode = "ai_execution_not_simulated" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "warning", ReasonCode: resp.FinalOutcomeCode, EntityID: aiAgent.ID, EntityName: aiAgent.Name}) return resp, nil } resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "ok", ReasonCode: "ok_human_only", EntityID: aiAgent.ID, EntityName: aiAgent.Name}) // Hop 3: Target Teams requestedTeamIDs := utils.SplitInt64s(aiAgent.TeamIDs) var teams []models.AgentTeam if len(requestedTeamIDs) > 0 { if err := db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams).Error; err != nil { return nil, setupPersistenceError(err) } } if len(teams) == 0 { resp.FinalOutcomeCode = "blocked_agent_no_teams" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "teams", Status: "error", ReasonCode: "blocked_agent_no_teams", EntityID: aiAgent.ID, EntityName: aiAgent.Name, QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), }) return resp, nil } teamNames := make([]string, 0, len(teams)) enabledTeamIDSet := make(map[int64]models.AgentTeam, len(teams)) enabledTeamIDs := make([]int64, 0, len(teams)) for _, t := range teams { teamNames = append(teamNames, t.Name) enabledTeamIDSet[t.ID] = t enabledTeamIDs = append(enabledTeamIDs, t.ID) } resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "teams", Status: "ok", ReasonCode: "ok", EntityName: strings.Join(teamNames, ", "), }) // Hop 4: Shift Schedules (Active Now) activeScheduleTeamIDs := make([]int64, 0) if err := db.Model(&models.AgentTeamSchedule{}).Distinct("team_id").Where("team_id IN ? AND status = ? AND start_at <= ? AND end_at > ?", enabledTeamIDs, enums.StatusOk, now, now).Pluck("team_id", &activeScheduleTeamIDs).Error; err != nil { return nil, setupPersistenceError(err) } activeScheduleTeamSet := make(map[int64]bool, len(activeScheduleTeamIDs)) activeScheduleNames := make([]string, 0, len(activeScheduleTeamIDs)) for _, tid := range activeScheduleTeamIDs { activeScheduleTeamSet[tid] = true if t, ok := enabledTeamIDSet[tid]; ok { activeScheduleNames = append(activeScheduleNames, t.Name) } } if len(activeScheduleTeamIDs) == 0 { resp.FinalOutcomeCode = "queued_off_hours_no_schedule" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "schedules", Status: "error", ReasonCode: "queued_off_hours_no_schedule", EntityName: strings.Join(teamNames, ", "), }) candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) if err != nil { return nil, err } resp.CandidateOperators = candidates return resp, nil } resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "schedules", Status: "ok", ReasonCode: "ok", EntityName: strings.Join(activeScheduleNames, ", "), }) // Hop 5: Operator Candidate Selection candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) if err != nil { return nil, err } resp.CandidateOperators = candidates eligibleCandidates, _, err := ConversationDispatchService.pickDispatchCandidates(enabledTeamIDs, now) if err != nil { return nil, err } if len(eligibleCandidates) == 0 { resp.FinalOutcomeCode = "queued_no_eligible_operator" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "operators", Status: "error", ReasonCode: "queued_no_eligible_operator", QuickFixAction: fixIfAllowed(QuickFixActionEnableMyProfile), }) return resp, nil } winner := eligibleCandidates[0] winnerTeamName := "" if t, ok := enabledTeamIDSet[winner.profile.TeamID]; ok { winnerTeamName = t.Name } winnerDisplayName := firstNonEmpty(winner.profile.DisplayName, fmt.Sprintf("#%d", winner.profile.UserID)) matched := &response.SimulatedOperatorCandidate{ UserID: winner.profile.UserID, DisplayName: winnerDisplayName, TeamID: winner.profile.TeamID, TeamName: winnerTeamName, Eligible: true, ActiveCount: int64(winner.activeCount), MaxConcurrentCount: winner.profile.MaxConcurrentCount, ServiceStatus: int(winner.profile.ServiceStatus), AutoAssignEnabled: winner.profile.AutoAssignEnabled, } resp.MatchedOperator = matched resp.WillReachOperator = true resp.FinalOutcomeCode = "assigned_to_operator" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "operators", Status: "ok", ReasonCode: "assigned_to_operator", EntityID: winner.profile.UserID, EntityName: winnerDisplayName, }) return resp, nil } func (s *setupReadinessService) buildCandidateListForTeams( enabledTeamIDSet map[int64]models.AgentTeam, activeScheduleTeamSet map[int64]bool, ) ([]response.SimulatedOperatorCandidate, error) { db := sqls.DB() teamIDs := make([]int64, 0, len(enabledTeamIDSet)) teamNameMap := make(map[int64]string, len(enabledTeamIDSet)) for id, team := range enabledTeamIDSet { teamIDs = append(teamIDs, id); teamNameMap[id] = team.Name } var allProfiles []models.AgentProfile if err := db.Where("team_id IN ? AND status <> ?", teamIDs, enums.StatusDeleted).Order("id ASC").Find(&allProfiles).Error; err != nil { return nil, setupPersistenceError(err) } var users []models.User if err := db.Where("id IN ?", extractProfileUserIDs(allProfiles)).Find(&users).Error; err != nil { return nil, setupPersistenceError(err) } userMap := make(map[int64]models.User, len(users)) for _, u := range users { userMap[u.ID] = u } activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles)) if err != nil { return nil, setupPersistenceError(err) } result := make([]response.SimulatedOperatorCandidate, 0, len(allProfiles)) for _, p := range allProfiles { u, hasUser := userMap[p.UserID] displayName := strings.TrimSpace(p.DisplayName) if displayName == "" && hasUser { displayName = firstNonEmpty(u.Nickname, u.Username) } if displayName == "" { displayName = fmt.Sprintf("#%d", p.UserID) } skipReason := "" switch { case p.Status != enums.StatusOk: skipReason = "profile_disabled" case !hasUser || u.Status != enums.StatusOk || u.DeletedAt != nil: skipReason = "user_disabled" case enabledTeamIDSet[p.TeamID].ID == 0: skipReason = "team_not_in_agent" case !activeScheduleTeamSet[p.TeamID]: skipReason = "team_off_hours" case !p.AutoAssignEnabled: skipReason = "auto_assign_off" case p.ServiceStatus != enums.ServiceStatusIdle: skipReason = "status_busy" case p.MaxConcurrentCount <= 0 || activeCounts[p.UserID] >= p.MaxConcurrentCount: skipReason = "at_capacity" } result = append(result, response.SimulatedOperatorCandidate{ UserID: p.UserID, DisplayName: displayName, TeamID: p.TeamID, TeamName: teamNameMap[p.TeamID], Eligible: skipReason == "", SkipReasonCode: skipReason, ActiveCount: int64(activeCounts[p.UserID]), MaxConcurrentCount: p.MaxConcurrentCount, ServiceStatus: int(p.ServiceStatus), AutoAssignEnabled: p.AutoAssignEnabled, }) } return result, nil } func (s *setupReadinessService) hasAnyPermission(operator *dto.AuthPrincipal, codes ...string) bool { if operator == nil || operator.UserID <= 0 { return false } for _, code := range codes { if slices.Contains(operator.Permissions, code) { return true } } return false } func (s *setupReadinessService) canManageTeamsAndSchedules(operator *dto.AuthPrincipal) bool { return s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code) } func (s *setupReadinessService) canManageOperatorProfile(operator *dto.AuthPrincipal) bool { return s.hasAnyPermission( operator, constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code, ) } func (s *setupReadinessService) canManageAIAgents(operator *dto.AuthPrincipal) bool { return s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code) } func (s *setupReadinessService) canManageChannels(operator *dto.AuthPrincipal) bool { return s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code) } func (s *setupReadinessService) canRunQuickFixAction(operator *dto.AuthPrincipal, action string) bool { switch action { case QuickFixActionEnsureTeamSchedule: return s.canManageTeamsAndSchedules(operator) case QuickFixActionEnableMyProfile: return s.canManageOperatorProfile(operator) case QuickFixActionFixAIAgentHandoff: return s.canManageAIAgents(operator) case QuickFixActionEnsureDefaultChannel: return s.canManageChannels(operator) case QuickFixActionQuickSetupAll: return s.canManageTeamsAndSchedules(operator) && s.canManageOperatorProfile(operator) && s.canManageAIAgents(operator) && s.canManageChannels(operator) default: return false } } func (s *setupReadinessService) filterStepQuickFixes(step response.SetupStepItem, operator *dto.AuthPrincipal) response.SetupStepItem { filteredActions := make([]string, 0, len(step.QuickFixActions)) for _, action := range step.QuickFixActions { if s.canRunQuickFixAction(operator, action) { filteredActions = append(filteredActions, action) } } step.QuickFixActions = filteredActions for i := range step.Issues { if step.Issues[i].QuickFixAction != "" && !s.canRunQuickFixAction(operator, step.Issues[i].QuickFixAction) { step.Issues[i].QuickFixAction = "" } } return step } // A quick fix is a two-phase operation. Omitted apply intent is read-only, even // for older clients. The fingerprint binds approval to the principal, named // targets, current configuration, and exact proposed field changes. type setupQuickFixPlan struct { changes []response.SetupQuickFixChange warnings []string operations []func(*gorm.DB) error snapshot any } func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*response.SetupQuickFixResponse, error) { if operator == nil || operator.UserID <= 0 { return nil, errorsx.UnauthorizedI18n("error.setup.authRequired") } req.Action = strings.TrimSpace(req.Action) switch req.Action { case QuickFixActionQuickSetupAll, QuickFixActionEnsureTeamSchedule, QuickFixActionEnableMyProfile, QuickFixActionFixAIAgentHandoff, QuickFixActionEnsureDefaultChannel: default: return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", req.Action) } if req.EntityID < 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } if !s.canRunQuickFixAction(operator, req.Action) { return nil, errorsx.ForbiddenI18n("error.e0225") } if req.Apply && strings.TrimSpace(req.PreviewToken) == "" { return nil, errorsx.InvalidParamI18n("error.setup.previewRequired") } res := &response.SetupQuickFixResponse{ AppliedActions: []string{}, Changes: []response.SetupQuickFixChange{}, WarningCodes: []string{}, } err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { db := ctx.Tx if req.Apply { // SQLite serializes writes; MySQL locks the configuration rows/ranges. db = db.Clauses(clause.Locking{Strength: "UPDATE"}) } plan, err := s.planQuickFix(db, req, operator) if err != nil { return err } encoded, err := json.Marshal([]any{operator.UserID, req.Action, req.EntityID, plan.changes, plan.snapshot}) if err != nil { return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed") } res.PreviewToken = fmt.Sprintf("%x", sha256.Sum256(encoded)) res.Changes = plan.changes res.WarningCodes = plan.warnings if !req.Apply { return nil } if req.PreviewToken != res.PreviewToken { return errorsx.InvalidParamI18n("error.setup.previewChanged") } for i, operation := range plan.operations { if err := operation(ctx.Tx); err != nil { return err } res.AppliedActions = append(res.AppliedActions, plan.changes[i].Operation+"_"+plan.changes[i].EntityType) } return nil }) if err != nil { var appError *errorsx.I18nError if errors.As(err, &appError) { return nil, err } return nil, setupPersistenceError(err) } res.Applied = req.Apply // Dispatch is deliberately outside the configuration transaction. A failure // does not roll back committed repairs and is visible to the caller. if res.Applied && len(res.AppliedActions) > 0 { if _, err := ConversationDispatchService.DispatchPendingConversations(0); err != nil { slog.Warn("setup repair committed but pending dispatch failed", "user_id", operator.UserID) res.WarningCodes = append(res.WarningCodes, "dispatch_failed") } } readiness, err := s.GetReadiness(operator) if err != nil { // Never report a committed mutation as a failed write that invites a retry. res.WarningCodes = append(res.WarningCodes, "readiness_refresh_failed") } else { res.Readiness = readiness } return res, nil } func setupPersistenceError(err error) error { if err == nil { return nil } return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed") } func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*setupQuickFixPlan, error) { plan := &setupQuickFixPlan{changes: []response.SetupQuickFixChange{}, warnings: []string{}, operations: []func(*gorm.DB) error{}} var teams []models.AgentTeam var agents []models.AIAgent var channels []models.Channel var profiles []models.AgentProfile // Deleted records are never update candidates. Keep them in the snapshot so // deletion cannot silently turn an approved update into a replacement create. // Each read gets a fresh statement; the shared handle may carry a row lock, // but must not retain the previous Find model/table or profile filter. for _, dest := range []any{&teams, &agents, &channels} { if err := db.Session(&gorm.Session{}).Order("id ASC").Find(dest).Error; err != nil { return nil, setupPersistenceError(err) } } if err := db.Session(&gorm.Session{}).Where("user_id = ?", operator.UserID).Order("id ASC").Find(&profiles).Error; err != nil { return nil, setupPersistenceError(err) } if req.Action == QuickFixActionEnableMyProfile && req.EntityID > 0 && (len(profiles) == 0 || profiles[0].ID != req.EntityID) { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } plan.snapshot = []any{teams, agents, channels, profiles} now := time.Now() audit := models.AuditFields{CreatedAt: now, UpdatedAt: now, CreateUserID: operator.UserID, UpdateUserID: operator.UserID, CreateUserName: operator.Username, UpdateUserName: operator.Username} require := func(codes ...string) error { if !s.hasAnyPermission(operator, codes...) { return errorsx.ForbiddenI18n("error.e0225") } return nil } add := func(kind string, id int64, name, operation string, before, after map[string]any, run func(*gorm.DB) error) { plan.changes = append(plan.changes, response.SetupQuickFixChange{EntityType: kind, EntityID: id, EntityName: name, Operation: operation, Before: before, After: after}) plan.operations = append(plan.operations, run) } update := func(model any, kind string, id int64, name string, before, after map[string]any, updatedAt time.Time) { for field, value := range after { if reflect.DeepEqual(before[field], value) { delete(before, field); delete(after, field) } } if len(after) == 0 { return } add(kind, id, name, "update", before, after, func(tx *gorm.DB) error { updates := make(map[string]any, len(after)+3) for key, value := range after { updates[key] = value } updates["updated_at"] = now updates["update_user_id"] = operator.UserID updates["update_user_name"] = operator.Username result := tx.Model(model).Where("id = ? AND status <> ? AND updated_at = ?", id, enums.StatusDeleted, updatedAt).Updates(updates) if result.Error != nil { return setupPersistenceError(result.Error) } if result.RowsAffected != 1 { return errorsx.InvalidParamI18n("error.setup.previewChanged") } return nil }) } all := req.Action == QuickFixActionQuickSetupAll if all && req.EntityID != 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } var team *models.AgentTeam nonDeletedTeams := 0 for i := range teams { if teams[i].Status != enums.StatusDeleted { nonDeletedTeams++ } if team == nil && teams[i].Status == enums.StatusOk { team = &teams[i] } } if req.Action == QuickFixActionEnsureTeamSchedule && req.EntityID > 0 { team = nil for i := range teams { if teams[i].ID == req.EntityID && teams[i].Status == enums.StatusOk { team = &teams[i] } } if team == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } } if (all || req.Action == QuickFixActionEnsureTeamSchedule) && team == nil && nonDeletedTeams == 0 && req.EntityID == 0 { if err := require(constants.PermissionAgentTeamCreate.Code); err != nil { return nil, err } team = &models.AgentTeam{Name: "Default Support Team", LeaderUserID: operator.UserID, Status: enums.StatusOk, AuditFields: audit} add("team", 0, team.Name, "create", nil, map[string]any{"name": team.Name, "leader_user_id": operator.UserID, "status": enums.StatusOk}, func(tx *gorm.DB) error { return setupPersistenceError(tx.Create(team).Error) }) } // A team being off duty is not a broken schedule. The existing schedule // editor is the only place to add explicit hours; quick fixes never add shifts. if all || req.Action == QuickFixActionEnsureTeamSchedule { plan.warnings = append(plan.warnings, "schedules_unchanged") } if team == nil { plan.warnings = append(plan.warnings, "active_team_required") } teamRef := func() any { if team == nil { return nil } if team.ID > 0 { return team.ID } return team.Name } if all || req.Action == QuickFixActionEnableMyProfile { if len(profiles) == 0 && team != nil { if err := require(constants.PermissionAgentCreate.Code); err != nil { return nil, err } profile := &models.AgentProfile{UserID: operator.UserID, AgentCode: fmt.Sprintf("AG%04d", operator.UserID), DisplayName: firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID)), ServiceStatus: enums.ServiceStatusIdle, MaxConcurrentCount: 10, PriorityLevel: 1, AutoAssignEnabled: true, ReceiveOfflineMessage: true, Status: enums.StatusOk, AuditFields: audit} add("operator", 0, profile.DisplayName, "create", nil, map[string]any{"user_id": operator.UserID, "team_id": teamRef(), "status": enums.StatusOk, "service_status": enums.ServiceStatusIdle, "auto_assign_enabled": true, "max_concurrent_count": 10, "priority_level": 1, "receive_offline_message": true}, func(tx *gorm.DB) error { profile.TeamID = team.ID return setupPersistenceError(tx.Create(profile).Error) }) } else if !all && len(profiles) > 0 { profile := profiles[0] if profile.Status == enums.StatusDeleted { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } before, after := map[string]any{}, map[string]any{} if profile.Status != enums.StatusOk { if err := require(constants.PermissionAgentUpdate.Code); err != nil { return nil, err } before["status"], after["status"] = profile.Status, enums.StatusOk } if profile.ServiceStatus != enums.ServiceStatusIdle { if err := require(constants.PermissionAgentUpdateStatus.Code, constants.PermissionAgentUpdate.Code); err != nil { return nil, err } before["service_status"], after["service_status"] = profile.ServiceStatus, enums.ServiceStatusIdle } if !profile.AutoAssignEnabled || profile.MaxConcurrentCount <= 0 { if err := require(constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdate.Code); err != nil { return nil, err } if !profile.AutoAssignEnabled { before["auto_assign_enabled"], after["auto_assign_enabled"] = false, true } if profile.MaxConcurrentCount <= 0 { before["max_concurrent_count"], after["max_concurrent_count"] = profile.MaxConcurrentCount, 10 } } // Do not silently move an existing operator to a different team. update(&models.AgentProfile{}, "operator", profile.ID, profile.DisplayName, before, after, profile.UpdatedAt) } } var agent *models.AIAgent nonDeletedAgents := 0 for i := range agents { if agents[i].Status != enums.StatusDeleted { nonDeletedAgents++ } if agent == nil && agents[i].Status == enums.StatusOk && agents[i].RolloutPercent > 0 && agents[i].PublishedRevisionID > 0 { agent = &agents[i] } } if all || req.Action == QuickFixActionFixAIAgentHandoff { if nonDeletedAgents == 0 && req.EntityID == 0 && team != nil { if err := require(constants.PermissionAIAgentCreate.Code); err != nil { return nil, err } agent = &models.AIAgent{Name: "Default Reception Agent", Status: enums.StatusOk, ServiceMode: enums.IMConversationServiceModeHumanOnly, RolloutPercent: 100, HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool, FallbackMode: enums.AIAgentFallbackModeHandoff, AutoHandoffOnFailure: true, ReplyTimeoutSeconds: 180, MaxSteps: 6, AuditFields: audit} add("ai_agent", 0, agent.Name, "create", nil, map[string]any{"name": agent.Name, "status": enums.StatusOk, "team_ids": teamRef(), "service_mode": agent.ServiceMode, "rollout_percent": 100, "handoff_mode": agent.HandoffMode, "fallback_mode": agent.FallbackMode, "auto_handoff_on_failure": true, "reply_timeout_seconds": 180, "max_steps": 6}, func(tx *gorm.DB) error { agent.TeamIDs = strconv.FormatInt(team.ID, 10) return setupPersistenceError(tx.Create(agent).Error) }) } else if !all { if req.EntityID <= 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } var target *models.AIAgent for i := range agents { if agents[i].ID == req.EntityID && agents[i].Status != enums.StatusDeleted { target = &agents[i] } } if target == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } if target.Status != enums.StatusOk || target.RolloutPercent <= 0 { plan.warnings = append(plan.warnings, "disabled_configuration_preserved") } else { if err := require(constants.PermissionAIAgentUpdate.Code); err != nil { return nil, err } before, after := map[string]any{}, map[string]any{} if !target.AutoHandoffOnFailure { before["auto_handoff_on_failure"], after["auto_handoff_on_failure"] = false, true } if target.ServiceMode == enums.IMConversationServiceModeAIOnly { before["service_mode"], after["service_mode"] = target.ServiceMode, enums.IMConversationServiceModeAIFirst } // Team membership and AI configuration need deliberate selection in // the existing editor, not expansion to every active team. if len(utils.SplitInt64s(target.TeamIDs)) == 0 { plan.warnings = append(plan.warnings, "agent_team_selection_required") } update(&models.AIAgent{}, "ai_agent", target.ID, target.Name, before, after, target.UpdatedAt) } } } if all || req.Action == QuickFixActionEnsureDefaultChannel { nonDeletedChannels := 0 for _, channel := range channels { if channel.Status != enums.StatusDeleted { nonDeletedChannels++ } } if nonDeletedChannels == 0 && req.EntityID == 0 && agent != nil && agent.PublishedRevisionID > 0 { if err := require(constants.PermissionChannelCreate.Code); err != nil { return nil, err } channel := &models.Channel{Name: "Default Web Support Channel", ChannelType: string(enums.ChannelTypeWeb), ChannelID: fmt.Sprintf("web_setup_%d_%d", operator.UserID, len(channels)+1), AIAgentRolloutPercent: 100, ConfigJSON: `{"position":"right"}`, Status: enums.StatusOk, AuditFields: audit} agentRef := any(agent.ID) if agent.ID == 0 { agentRef = agent.Name } add("channel", 0, channel.Name, "create", nil, map[string]any{"name": channel.Name, "channel_id": channel.ChannelID, "channel_type": channel.ChannelType, "ai_agent_id": agentRef, "status": enums.StatusOk, "ai_agent_rollout_percent": 100, "config_json": channel.ConfigJSON}, func(tx *gorm.DB) error { channel.AIAgentID = agent.ID return setupPersistenceError(tx.Create(channel).Error) }) } else if nonDeletedChannels == 0 && req.EntityID == 0 { // Match the normal channel editor's publication guard. Publishing an // agent is a separate reviewed workflow, never implicit in setup. plan.warnings = append(plan.warnings, "agent_publication_required") } else if !all { if req.EntityID <= 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } var target *models.Channel for i := range channels { if channels[i].ID == req.EntityID && channels[i].Status != enums.StatusDeleted { target = &channels[i] } } if target == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } // Never replace an explicitly configured channel route or rollout with // the first agent in the database. Use the existing channel editor. plan.warnings = append(plan.warnings, "channel_selection_required") } } if all { plan.warnings = append(plan.warnings, "existing_configuration_preserved") } return plan, nil } func extractProfileUserIDs(profiles []models.AgentProfile) []int64 { ids := make([]int64, 0, len(profiles)) seen := make(map[int64]bool, len(profiles)) for _, p := range profiles { if p.UserID > 0 && !seen[p.UserID] { seen[p.UserID] = true ids = append(ids, p.UserID) } } return ids } func firstNonEmpty(values ...string) string { for _, v := range values { trimmed := strings.TrimSpace(v) if trimmed != "" { return trimmed } } return "" }