Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions backend/internal/application/conversation/service_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
63 changes: 63 additions & 0 deletions backend/internal/application/conversation/service_metadata_test.go
Original file line number Diff line number Diff line change
@@ -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)}

Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions frontend/features/chat/hooks/use-chat-message-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading