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
89 changes: 60 additions & 29 deletions backend/internal/application/conversation/service_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import (
)

const (
conversationMetadataMessageMaxTokens = int64(5000)
conversationMetadataTitlePrompt = `Generate a concise title from the first conversation turn below. Return ONLY a valid JSON object.
conversationMetadataMessageMaxTokens = int64(5000)
conversationFirstMessageTitleMaxRunes = 20
conversationAutoGenerateTitleSettingKey = "chat.auto_generate_title"
conversationMetadataTitlePrompt = `Generate a concise title from the first conversation turn below. Return ONLY a valid JSON object.

## Constraints
1. **Content**: Reflect the primary topic, goal, or main subject.
Expand Down Expand Up @@ -72,9 +74,6 @@ func (s *Service) maybeGenerateConversationMetadataAsync(conversation model.Conv
}

func (s *Service) generateConversationMetadata(ctx context.Context, conversation model.Conversation, userMsg model.Message, assistantMsg model.Message) (*model.Conversation, error) {
if s.routeResolver == nil || s.llmClient == nil {
return nil, nil
}
cfg := s.cfg.Snapshot()
messages := buildConversationMetadataMessages(userMsg, assistantMsg)

Expand All @@ -95,7 +94,13 @@ func (s *Service) generateConversationMetadata(ctx context.Context, conversation
}
}

if shouldAutoReplaceConversationTitle(conversation.Title) {
shouldReplaceTitle := shouldAutoReplaceConversationTitle(conversation.Title)
shouldGenerateTitle := shouldReplaceTitle && s.autoGenerateConversationTitleEnabled(ctx, conversation.UserID)
if shouldReplaceTitle && !shouldGenerateTitle {
title = conversationTitleFromFirstUserMessage(userMsg.Content)
}

if s.routeResolver != nil && s.llmClient != nil && shouldGenerateTitle {
wg.Add(1)
go func() {
defer wg.Done()
Expand All @@ -112,29 +117,31 @@ func (s *Service) generateConversationMetadata(ctx context.Context, conversation
}()
}

wg.Add(1)
go func() {
defer wg.Done()
labelsPrompt := renderConversationMetadataPrompt(cfg.ConversationLabelsPrompt, conversationMetadataLabelsPrompt, messages)
labelsOut, err := s.callConversationMetadataLLM(ctx, cfg.ConversationTaskModel, conversation.Model, conversation.UserID, conversation.ID, labelsPrompt)
if err != nil {
setGenerateErr(err)
return
}
s.recordBasicServiceUsage(ctx, conversation.UserID, conversation.ID, "labels", "标签", labelsOut.PlatformModelName, labelsOut.RoutedBindingCode, labelsOut.ProviderProtocol, labelsOut.UpstreamName, labelsOut.UpstreamModel, "5m", labelsOut.Usage, labelsOut.Messages, labelsOut.Text, labelsOut.LatencyMS)
labels := sanitizeGeneratedConversationLabels(parseGeneratedConversationLabels(labelsOut.Text))
if len(labels) == 0 {
return
}
raw, marshalErr := json.Marshal(labels)
if marshalErr != nil {
setGenerateErr(marshalErr)
return
}
mu.Lock()
labelsJSON = string(raw)
mu.Unlock()
}()
if s.routeResolver != nil && s.llmClient != nil {
wg.Add(1)
go func() {
defer wg.Done()
labelsPrompt := renderConversationMetadataPrompt(cfg.ConversationLabelsPrompt, conversationMetadataLabelsPrompt, messages)
labelsOut, err := s.callConversationMetadataLLM(ctx, cfg.ConversationTaskModel, conversation.Model, conversation.UserID, conversation.ID, labelsPrompt)
if err != nil {
setGenerateErr(err)
return
}
s.recordBasicServiceUsage(ctx, conversation.UserID, conversation.ID, "labels", "标签", labelsOut.PlatformModelName, labelsOut.RoutedBindingCode, labelsOut.ProviderProtocol, labelsOut.UpstreamName, labelsOut.UpstreamModel, "5m", labelsOut.Usage, labelsOut.Messages, labelsOut.Text, labelsOut.LatencyMS)
labels := sanitizeGeneratedConversationLabels(parseGeneratedConversationLabels(labelsOut.Text))
if len(labels) == 0 {
return
}
raw, marshalErr := json.Marshal(labels)
if marshalErr != nil {
setGenerateErr(marshalErr)
return
}
mu.Lock()
labelsJSON = string(raw)
mu.Unlock()
}()
}

wg.Wait()
mu.Lock()
Expand Down Expand Up @@ -388,6 +395,19 @@ func sanitizeGeneratedConversationTitle(raw string) string {
return strings.TrimSpace(value)
}

func conversationTitleFromFirstUserMessage(content string) string {
value := strings.Join(strings.Fields(strings.TrimSpace(content)), " ")
value = strings.Trim(value, " \t\r\n\"'`“”‘’")
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) > conversationFirstMessageTitleMaxRunes {
value = string(runes[:conversationFirstMessageTitleMaxRunes])
}
return strings.TrimSpace(value)
}

func sanitizeGeneratedConversationLabels(raw []string) []string {
seen := make(map[string]struct{}, len(raw))
labels := make([]string, 0, len(raw))
Expand All @@ -414,6 +434,17 @@ func sanitizeGeneratedConversationLabels(raw []string) []string {
return labels
}

func (s *Service) autoGenerateConversationTitleEnabled(ctx context.Context, userID uint) bool {
value, err := s.repo.GetUserSettingValue(ctx, userID, conversationAutoGenerateTitleSettingKey)
if err != nil {
if s.logger != nil {
s.logger.Warn("conversation_title_setting_load_failed", zap.Uint("user_id", userID), zap.Error(err))
}
return true
}
return strings.TrimSpace(strings.ToLower(value)) != "false"
}

func shouldAutoReplaceConversationTitle(title string) bool {
value := strings.TrimSpace(strings.ToLower(title))
switch value {
Expand Down
14 changes: 14 additions & 0 deletions backend/internal/application/conversation/service_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,17 @@ func TestParseGeneratedConversationTitleRejectsDirtyOutput(t *testing.T) {
}
}
}

func TestConversationTitleFromFirstUserMessage(t *testing.T) {
cases := map[string]string{
" 这是一条很长的第一条用户消息,用来测试标题截断 ": "这是一条很长的第一条用户消息,用来测试标",
"\n\nhello world from DEEIX\n": "hello world from DEE",
"\"简短标题\"": "简短标题",
" ": "",
}
for input, want := range cases {
if got := conversationTitleFromFirstUserMessage(input); got != want {
t.Fatalf("unexpected first-message title for %q: got %q, want %q", input, got, want)
}
}
}
2 changes: 2 additions & 0 deletions backend/internal/application/usersettings/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var allowedKeys = map[string]string{
"chat.show_latency": "true",
"chat.show_billing_cost": "true",
"chat.default_model": "",
"chat.auto_generate_title": "true",
"chat.context_compact_auto": "true",
"chat.markdown_render": "true",
"chat.restore_draft_on_failure": "true",
Expand All @@ -40,6 +41,7 @@ var boolKeys = map[string]bool{
"chat.show_model_info": true,
"chat.show_latency": true,
"chat.show_billing_cost": true,
"chat.auto_generate_title": true,
"chat.context_compact_auto": true,
"chat.markdown_render": true,
"chat.restore_draft_on_failure": true,
Expand Down
13 changes: 9 additions & 4 deletions frontend/features/chat/hooks/use-chat-message-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,13 @@ function normalizeLabelsJSON(value: string | null | undefined): string {
return normalized && normalized !== "null" ? normalized : "[]";
}

function shouldRefreshGeneratedConversationMetadata(item: ConversationDTO | null): boolean {
return item !== null && item.messageCount === 0;
function isPlaceholderConversationTitle(title: string): boolean {
const value = title.trim().toLowerCase();
return ["", "new conversation", "untitled", "新会话", "新对话", "新的对话"].includes(value);
}

function shouldRefreshGeneratedConversationMetadata(item: ConversationDTO | null, visibleMessageCount: number): boolean {
return visibleMessageCount === 0 || item?.messageCount === 0;
}

function hasGeneratedConversationMetadataChanged(
Expand All @@ -140,7 +145,7 @@ function hasGeneratedConversationMetadataChanged(
): boolean {
const previousTitle = previous?.title?.trim() ?? "";
const nextTitle = next.title.trim();
if (nextTitle && nextTitle !== previousTitle) {
if (nextTitle && nextTitle !== previousTitle && !isPlaceholderConversationTitle(nextTitle)) {
return true;
}
return normalizeLabelsJSON(next.labelsJSON) !== normalizeLabelsJSON(previous?.labelsJSON);
Expand Down Expand Up @@ -424,7 +429,7 @@ export function useChatMessageSubmit({
window.history.replaceState(null, "", `/chat?conversation_id=${created.publicID}`);
onConversationCreated?.(created.publicID);
}
const shouldRefreshConversationMetadata = shouldRefreshGeneratedConversationMetadata(targetConversation);
const shouldRefreshConversationMetadata = shouldRefreshGeneratedConversationMetadata(targetConversation, visibleMessageCount);

const commonStreamPayload = {
model: requestPlatformModelName,
Expand Down
13 changes: 13 additions & 0 deletions frontend/features/settings/components/sections/settings-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,19 @@ export function SettingsChat() {
/>
)}
</SettingsFieldRow>
<div className="pt-4">
<SettingsFieldRow
title={t("defaultModel.autoTitle")}
description={t("defaultModel.autoTitleDescription")}
>
<Switch
checked={settings.autoGenerateTitle}
onCheckedChange={handleBool("chat.auto_generate_title", "autoGenerateTitle")}
disabled={loading}
aria-label={t("defaultModel.autoTitle")}
/>
</SettingsFieldRow>
</div>
</SettingsFieldList>
</SettingsSection>

Expand Down
1 change: 1 addition & 0 deletions frontend/features/settings/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type ChatSettings = {
showLatency: boolean;
showBillingCost: boolean;
markdownRender: boolean;
autoGenerateTitle: boolean;
contextCompactAuto: boolean;
restoreDraftOnFailure: boolean;
preserveConversationDrafts: boolean;
Expand Down
2 changes: 2 additions & 0 deletions frontend/features/settings/utils/chat-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
showLatency: true,
showBillingCost: true,
markdownRender: true,
autoGenerateTitle: true,
contextCompactAuto: false,
restoreDraftOnFailure: true,
preserveConversationDrafts: true,
Expand All @@ -35,6 +36,7 @@ export function parseChatSettings(map: UserSettingsMap): ChatSettings {
showLatency: map["chat.show_latency"] !== "false",
showBillingCost: map["chat.show_billing_cost"] !== "false",
markdownRender: map["chat.markdown_render"] !== "false",
autoGenerateTitle: map["chat.auto_generate_title"] !== "false",
contextCompactAuto: map["chat.context_compact_auto"] === "true",
restoreDraftOnFailure: map["chat.restore_draft_on_failure"] !== "false",
preserveConversationDrafts: map["chat.preserve_conversation_drafts"] !== "false",
Expand Down
4 changes: 3 additions & 1 deletion frontend/i18n/messages/en-US/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,9 @@
"sectionTitle": "Default model",
"title": "Default model for new chats",
"description": "The model preselected for new chats. Leave empty to use the first available system-recommended model.",
"systemRecommended": "System default"
"systemRecommended": "System default",
"autoTitle": "Auto-generate titles",
"autoTitleDescription": "When disabled, new chats use the first 20 characters of the first message as the title."
},
"input": {
"sectionTitle": "Input and sending",
Expand Down
4 changes: 3 additions & 1 deletion frontend/i18n/messages/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,9 @@
"sectionTitle": "默认模型",
"title": "新会话默认模型",
"description": "开启新对话时预选的模型。留空则使用系统推荐的第一个可用模型。",
"systemRecommended": "系统默认"
"systemRecommended": "系统默认",
"autoTitle": "自动生成标题",
"autoTitleDescription": "关闭后,新对话使用第一条消息的前 20 个字符作为标题。"
},
"input": {
"sectionTitle": "输入与发送",
Expand Down
Loading