diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index fb18455..11cbd8e 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -107,6 +107,7 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha } var conversation *models.Conversation + var welcomeMessage *models.Message created := false if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { customerID, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) @@ -149,7 +150,11 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha if err := ConversationParticipantService.CreateCustomerParticipant(ctx, conversation.ID, externalUser); err != nil { return err } - return ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", "") + if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", ""); err != nil { + return err + } + welcomeMessage, err = MessageService.CreateAIWelcomeMessageTx(ctx, conversation, aiAgent, now) + return err }); err != nil { return nil, err } @@ -162,6 +167,12 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha // 推送会话创建事件 WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationCreated) + if welcomeMessage != nil { + if updatedConversation := s.Get(conversation.ID); updatedConversation != nil { + WsService.PublishMessageCreated(updatedConversation, welcomeMessage) + WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated) + } + } if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly { if _, err := ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent); err != nil { diff --git a/internal/services/message_service.go b/internal/services/message_service.go index 2912384..73600d3 100644 --- a/internal/services/message_service.go +++ b/internal/services/message_service.go @@ -258,6 +258,97 @@ func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int }, nil) } +func (s *messageService) CreateAIWelcomeMessageTx(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) { + if ctx == nil || conversation == nil || aiAgent == nil || strings.TrimSpace(aiAgent.WelcomeMessage) == "" { + return nil, nil + } + + content, payload, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, aiAgent.WelcomeMessage, "") + if err != nil { + return nil, err + } + if strs.IsBlank(content) && strs.IsBlank(payload) { + return nil, nil + } + + operator := &dto.AuthPrincipal{ + UserID: 0, + Username: "system", + Nickname: "system", + } + message := &models.Message{ + ConversationID: conversation.ID, + SenderType: enums.IMSenderTypeAI, + SenderID: aiAgent.ID, + MessageType: enums.IMMessageTypeText, + Content: content, + Payload: payload, + SeqNo: repositories.MessageRepository.NextSeqNo(ctx.Tx, conversation.ID), + SendStatus: enums.IMMessageStatusSent, + SentAt: &now, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: operator.UserID, + CreateUserName: operator.Username, + UpdatedAt: now, + UpdateUserID: operator.UserID, + UpdateUserName: operator.Username, + }, + } + if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil { + return nil, err + } + + if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message, now); err != nil { + return nil, err + } + agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) + agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(agentReadState), enums.IMSenderTypeCustomer) + if err != nil { + return nil, err + } + customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI) + if err != nil { + return nil, err + } + + conversationUpdates := map[string]any{ + "last_message_id": message.ID, + "last_message_at": now, + "last_active_at": now, + "last_message_summary": limitText(summary, 255), + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + "agent_unread_count": agentUnreadCount, + "customer_unread_count": customerUnreadCount, + } + if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil { + return nil, err + } + if err := ConversationEventLogService.CreateEvent(ctx, + conversation.ID, + enums.IMEventTypeMessageSend, + enums.IMSenderTypeAI, + 0, + enums.GetIMSenderTypeLabel(enums.IMSenderTypeAI)+"发送消息", + "", + ); err != nil { + return nil, err + } + + conversation.LastMessageID = message.ID + conversation.LastMessageAt = now + conversation.LastActiveAt = now + conversation.LastMessageSummary = limitText(summary, 255) + conversation.AgentUnreadCount = int(agentUnreadCount) + conversation.CustomerUnreadCount = int(customerUnreadCount) + conversation.UpdatedAt = now + conversation.UpdateUserID = operator.UserID + conversation.UpdateUserName = operator.Username + return message, nil +} + func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser) (*models.Message, error) { ext := external return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext) diff --git a/internal/services/message_service_test.go b/internal/services/message_service_test.go index 19ce11a..dbfa764 100644 --- a/internal/services/message_service_test.go +++ b/internal/services/message_service_test.go @@ -1,11 +1,18 @@ package services import ( + "strings" "testing" "time" "cs-agent/internal/models" "cs-agent/internal/pkg/enums" + "cs-agent/internal/pkg/openidentity" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" ) func TestAllowAIMessageOnPendingHandoff(t *testing.T) { @@ -27,3 +34,210 @@ func TestAllowAIMessageOnPendingHandoff(t *testing.T) { func ptrTime(v time.Time) *time.Time { return &v } + +func setupMessageWelcomeTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dbName := "message_welcome_test_" + 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 db: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sqlite db: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Fatalf("close sqlite db: %v", err) + } + }) + if err := db.AutoMigrate( + &models.AIAgent{}, + &models.Channel{}, + &models.ChannelMessageOutbox{}, + &models.Customer{}, + &models.CustomerIdentity{}, + &models.Conversation{}, + &models.ConversationParticipant{}, + &models.ConversationReadState{}, + &models.ConversationEventLog{}, + &models.Message{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + return db +} + +func createWelcomeTestAIAgent(t *testing.T, db *gorm.DB, welcomeMessage string) *models.AIAgent { + t.Helper() + + now := time.Now() + aiAgent := &models.AIAgent{ + Name: "welcome-test-agent", + Status: enums.StatusOk, + ServiceMode: enums.IMConversationServiceModeAIOnly, + WelcomeMessage: welcomeMessage, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(aiAgent).Error; err != nil { + t.Fatalf("create ai agent: %v", err) + } + return aiAgent +} + +func welcomeTestExternalUser(id string) openidentity.ExternalUser { + return openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: id, + ExternalName: "访客" + id, + } +} + +func TestConversationCreateCreatesAIWelcomeMessage(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, " 您好,请问有什么可以帮您? ") + + conversation, err := ConversationService.Create(welcomeTestExternalUser("welcome-1"), 11, aiAgent.ID) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + if conversation == nil { + t.Fatalf("expected conversation") + } + + var messages []models.Message + if err := db.Find(&messages).Error; err != nil { + t.Fatalf("find messages: %v", err) + } + if len(messages) != 1 { + t.Fatalf("expected exactly one welcome message, got %d", len(messages)) + } + message := messages[0] + if message.ConversationID != conversation.ID { + t.Fatalf("expected conversation_id %d, got %d", conversation.ID, message.ConversationID) + } + if message.SenderType != enums.IMSenderTypeAI { + t.Fatalf("expected sender type ai, got %q", message.SenderType) + } + if message.SenderID != aiAgent.ID { + t.Fatalf("expected sender id %d, got %d", aiAgent.ID, message.SenderID) + } + if message.MessageType != enums.IMMessageTypeText { + t.Fatalf("expected message type text, got %q", message.MessageType) + } + if message.Content != "您好,请问有什么可以帮您?" { + t.Fatalf("expected trimmed welcome content, got %q", message.Content) + } + if message.SeqNo != 1 { + t.Fatalf("expected seq no 1, got %d", message.SeqNo) + } + if message.SendStatus != enums.IMMessageStatusSent { + t.Fatalf("expected sent status, got %d", message.SendStatus) + } + + var updated models.Conversation + if err := db.First(&updated, conversation.ID).Error; err != nil { + t.Fatalf("find conversation: %v", err) + } + if updated.LastMessageID != message.ID { + t.Fatalf("expected last message id %d, got %d", message.ID, updated.LastMessageID) + } + if updated.LastMessageSummary != "您好,请问有什么可以帮您?" { + t.Fatalf("expected last message summary, got %q", updated.LastMessageSummary) + } + if updated.CustomerUnreadCount != 1 { + t.Fatalf("expected customer unread count 1, got %d", updated.CustomerUnreadCount) + } + if updated.AgentUnreadCount != 0 { + t.Fatalf("expected agent unread count 0, got %d", updated.AgentUnreadCount) + } +} + +func TestConversationCreateDoesNotDuplicateWelcomeMessageForExistingConversation(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询") + external := welcomeTestExternalUser("u-2") + + first, err := ConversationService.Create(external, 11, aiAgent.ID) + if err != nil { + t.Fatalf("create first conversation: %v", err) + } + second, err := ConversationService.Create(external, 11, aiAgent.ID) + if err != nil { + t.Fatalf("create second conversation: %v", err) + } + if first.ID != second.ID { + t.Fatalf("expected existing conversation id %d, got %d", first.ID, second.ID) + } + + var count int64 + if err := db.Model(&models.Message{}).Where("conversation_id = ?", first.ID).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 1 { + t.Fatalf("expected exactly one welcome message, got %d", count) + } +} + +func TestConversationCreateSkipsBlankWelcomeMessage(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, " ") + + conversation, err := ConversationService.Create(welcomeTestExternalUser("blank-welcome-1"), 11, aiAgent.ID) + if err != nil { + t.Fatalf("create conversation: %v", err) + } + if conversation == nil { + t.Fatalf("expected conversation") + } + + var count int64 + if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 0 { + t.Fatalf("expected no welcome messages, got %d", count) + } + + var updated models.Conversation + if err := db.First(&updated, conversation.ID).Error; err != nil { + t.Fatalf("find conversation: %v", err) + } + if updated.LastMessageID != 0 { + t.Fatalf("expected last message id 0, got %d", updated.LastMessageID) + } + if updated.CustomerUnreadCount != 0 { + t.Fatalf("expected customer unread count 0, got %d", updated.CustomerUnreadCount) + } +} + +func TestConversationCreateWelcomeMessageDoesNotTriggerAIReplyHook(t *testing.T) { + db := setupMessageWelcomeTestDB(t) + aiAgent := createWelcomeTestAIAgent(t, db, "欢迎咨询") + + previousHook := TriggerAIReplyAsyncHook + called := false + TriggerAIReplyAsyncHook = func(conversation models.Conversation, message models.Message) { + called = true + } + t.Cleanup(func() { + TriggerAIReplyAsyncHook = previousHook + }) + + if _, err := ConversationService.Create(welcomeTestExternalUser("hook-welcome-1"), 11, aiAgent.ID); err != nil { + t.Fatalf("create conversation: %v", err) + } + if called { + t.Fatalf("expected welcome message not to trigger ai reply hook") + } +} diff --git a/web/components/kefu/message-list.tsx b/web/components/kefu/message-list.tsx index b2e7d77..dd30af4 100644 --- a/web/components/kefu/message-list.tsx +++ b/web/components/kefu/message-list.tsx @@ -232,6 +232,12 @@ export const KefuMessageList = forwardRef ) : null} + {safeMessages.length === 0 ? ( +
+ 请描述你的问题,我们会尽快为你处理。 +
+ ) : null} + {safeMessages.map((message, index) => { const previousMessage = index > 0 ? safeMessages[index - 1] : null const showTimeline =