From 83240cfde436d35aa1d97865e03cb3411c1aed11 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:14:52 +0800 Subject: [PATCH] fix: preserve conversation title after failed first generation --- .../conversation/service_message_send.go | 1 + .../conversation/service_metadata.go | 34 ++++++++++ .../conversation/service_metadata_test.go | 63 +++++++++++++++++++ .../chat/hooks/use-chat-message-submit.ts | 15 +++++ 4 files changed, 113 insertions(+) diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index 2cfd745c..02398d4d 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -376,6 +376,7 @@ func (s *Service) sendMessageInternal( userMessage.SourcePublicID = branchState.SourcePublicID assistantMessage.ParentPublicID = userMessage.PublicID } + s.persistInitialConversationFallbackTitle(ctx, *conversation, *userMessage) traceRecorder = newMessageTraceRecorder(s, ctx, assistantMessage, input.OnEvent) if s.routeResolver == nil || s.llmClient == nil { diff --git a/backend/internal/application/conversation/service_metadata.go b/backend/internal/application/conversation/service_metadata.go index 8e81b1f3..13c86864 100644 --- a/backend/internal/application/conversation/service_metadata.go +++ b/backend/internal/application/conversation/service_metadata.go @@ -434,6 +434,40 @@ func (s *Service) persistConversationFallbackTitle( } } +func (s *Service) persistInitialConversationFallbackTitle( + ctx context.Context, + conversation model.Conversation, + currentUserMessage model.Message, +) { + if !shouldAutoReplaceConversationTitle(conversation.Title) { + return + } + candidate := currentUserMessage + messages, err := s.repo.ListAllMessages(ctx, conversation.ID) + if err != nil { + if s.logger != nil { + s.logger.Warn("conversation_initial_title_message_load_failed", + zap.Uint("conversation_id", conversation.ID), + zap.Error(err), + ) + } + return + } + if firstUserMessage, ok := firstTitleableConversationUserMessage(messages); ok { + candidate = firstUserMessage + } + s.persistConversationFallbackTitle(ctx, conversation, candidate) +} + +func firstTitleableConversationUserMessage(messages []model.Message) (model.Message, bool) { + for _, message := range messages { + if message.Role == "user" && strings.TrimSpace(message.Content) != "" { + return message, true + } + } + return model.Message{}, false +} + func renderConversationMetadataPrompt(raw string, fallback string, messages string) string { prompt := strings.TrimSpace(raw) if prompt == "" { diff --git a/backend/internal/application/conversation/service_metadata_test.go b/backend/internal/application/conversation/service_metadata_test.go index f417f655..7095dc3c 100644 --- a/backend/internal/application/conversation/service_metadata_test.go +++ b/backend/internal/application/conversation/service_metadata_test.go @@ -1,13 +1,36 @@ package conversation import ( + "context" "errors" "strings" "testing" model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" ) +type initialFallbackTitleRepositoryStub struct { + repository.ConversationRepository + messages []model.Message + patch repository.ConversationMetadataPatch + listCalls int +} + +func (s *initialFallbackTitleRepositoryStub) ListAllMessages(context.Context, uint) ([]model.Message, error) { + s.listCalls++ + return append([]model.Message(nil), s.messages...), nil +} + +func (s *initialFallbackTitleRepositoryStub) UpdateConversationMetadata( + _ context.Context, + conversationID uint, + patch repository.ConversationMetadataPatch, +) (*model.Conversation, error) { + s.patch = patch + return &model.Conversation{ID: conversationID, Title: patch.Title}, nil +} + func TestBuildConversationMetadataMessagesTruncatesToBudget(t *testing.T) { userMsg := model.Message{Content: strings.Repeat("用户输入内容", 6000)} @@ -136,6 +159,46 @@ func TestConversationFallbackTitlePatchOnlyReplacesPlaceholder(t *testing.T) { } } +func TestPersistInitialConversationFallbackTitleUsesFirstStoredUserMessage(t *testing.T) { + repo := &initialFallbackTitleRepositoryStub{messages: []model.Message{ + {ID: 1, Role: "user", Content: "第一条失败消息", Status: "error"}, + {ID: 2, Role: "assistant", Status: "error"}, + {ID: 3, Role: "user", Content: "第二条成功消息", Status: "pending"}, + }} + service := &Service{repo: repo} + service.persistInitialConversationFallbackTitle( + t.Context(), + model.Conversation{ID: 1, Title: "新对话", MessageCount: 2}, + model.Message{ID: 3, Role: "user", Content: "第二条成功消息"}, + ) + + if repo.patch.Title != "第一条失败消息" { + t.Fatalf("expected the first failed user message to remain the fallback title, got %q", repo.patch.Title) + } + if repo.listCalls != 1 { + t.Fatalf("expected existing conversation history to be loaded once, got %d", repo.listCalls) + } +} + +func TestPersistInitialConversationFallbackTitleUsesCurrentFirstMessage(t *testing.T) { + repo := &initialFallbackTitleRepositoryStub{messages: []model.Message{{ + ID: 1, Role: "user", Content: "第一条消息", Status: "pending", + }}} + service := &Service{repo: repo} + service.persistInitialConversationFallbackTitle( + t.Context(), + model.Conversation{ID: 1, Title: "新对话", MessageCount: 0}, + model.Message{ID: 1, Role: "user", Content: "第一条消息"}, + ) + + if repo.patch.Title != "第一条消息" { + t.Fatalf("expected current first user message as fallback title, got %q", repo.patch.Title) + } + if repo.listCalls != 1 { + t.Fatalf("expected the persisted first message to be loaded once, got %d calls", repo.listCalls) + } +} + func TestFallbackTitleSettlementOnlyRunsForCanceledGeneration(t *testing.T) { userMsg := &model.Message{Content: "首条用户消息"} if !shouldPersistConversationFallbackTitleAfterSend(ErrMessageGenerationCanceled, userMsg) { diff --git a/frontend/features/chat/hooks/use-chat-message-submit.ts b/frontend/features/chat/hooks/use-chat-message-submit.ts index 29705ad8..bb500e7d 100644 --- a/frontend/features/chat/hooks/use-chat-message-submit.ts +++ b/frontend/features/chat/hooks/use-chat-message-submit.ts @@ -1398,6 +1398,21 @@ export function useChatMessageSubmit({ }, })); toast.error(t("sendFailed"), { description: errorSummary }); + if (targetConversationID) { + const failedConversationID = targetConversationID; + void resolveAccessToken() + .then((latestToken) => + latestToken ? getConversation(latestToken, failedConversationID) : null, + ) + .then((latestConversation) => { + if (latestConversation) { + touchByPublicID(failedConversationID, latestConversation); + } + }) + .catch(() => { + // The next conversation list load will reconcile a failed refresh. + }); + } if (targetConversationID && conversationScopeKeyRef.current === targetConversationScopeKey) { reload(); }