Skip to content

Commit a562d9d

Browse files
authored
Merge pull request #83 from DEEIX-AI/auto_title_generate
feat: implement auto generate title logic for conversations
2 parents 7bbada7 + 27169ee commit a562d9d

9 files changed

Lines changed: 107 additions & 35 deletions

File tree

backend/internal/application/conversation/service_metadata.go

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import (
1414
)
1515

1616
const (
17-
conversationMetadataMessageMaxTokens = int64(5000)
18-
conversationMetadataTitlePrompt = `Generate a concise title from the first conversation turn below. Return ONLY a valid JSON object.
17+
conversationMetadataMessageMaxTokens = int64(5000)
18+
conversationFirstMessageTitleMaxRunes = 20
19+
conversationAutoGenerateTitleSettingKey = "chat.auto_generate_title"
20+
conversationMetadataTitlePrompt = `Generate a concise title from the first conversation turn below. Return ONLY a valid JSON object.
1921
2022
## Constraints
2123
1. **Content**: Reflect the primary topic, goal, or main subject.
@@ -72,9 +74,6 @@ func (s *Service) maybeGenerateConversationMetadataAsync(conversation model.Conv
7274
}
7375

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

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

98-
if shouldAutoReplaceConversationTitle(conversation.Title) {
97+
shouldReplaceTitle := shouldAutoReplaceConversationTitle(conversation.Title)
98+
shouldGenerateTitle := shouldReplaceTitle && s.autoGenerateConversationTitleEnabled(ctx, conversation.UserID)
99+
if shouldReplaceTitle && !shouldGenerateTitle {
100+
title = conversationTitleFromFirstUserMessage(userMsg.Content)
101+
}
102+
103+
if s.routeResolver != nil && s.llmClient != nil && shouldGenerateTitle {
99104
wg.Add(1)
100105
go func() {
101106
defer wg.Done()
@@ -112,29 +117,31 @@ func (s *Service) generateConversationMetadata(ctx context.Context, conversation
112117
}()
113118
}
114119

115-
wg.Add(1)
116-
go func() {
117-
defer wg.Done()
118-
labelsPrompt := renderConversationMetadataPrompt(cfg.ConversationLabelsPrompt, conversationMetadataLabelsPrompt, messages)
119-
labelsOut, err := s.callConversationMetadataLLM(ctx, cfg.ConversationTaskModel, conversation.Model, conversation.UserID, conversation.ID, labelsPrompt)
120-
if err != nil {
121-
setGenerateErr(err)
122-
return
123-
}
124-
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)
125-
labels := sanitizeGeneratedConversationLabels(parseGeneratedConversationLabels(labelsOut.Text))
126-
if len(labels) == 0 {
127-
return
128-
}
129-
raw, marshalErr := json.Marshal(labels)
130-
if marshalErr != nil {
131-
setGenerateErr(marshalErr)
132-
return
133-
}
134-
mu.Lock()
135-
labelsJSON = string(raw)
136-
mu.Unlock()
137-
}()
120+
if s.routeResolver != nil && s.llmClient != nil {
121+
wg.Add(1)
122+
go func() {
123+
defer wg.Done()
124+
labelsPrompt := renderConversationMetadataPrompt(cfg.ConversationLabelsPrompt, conversationMetadataLabelsPrompt, messages)
125+
labelsOut, err := s.callConversationMetadataLLM(ctx, cfg.ConversationTaskModel, conversation.Model, conversation.UserID, conversation.ID, labelsPrompt)
126+
if err != nil {
127+
setGenerateErr(err)
128+
return
129+
}
130+
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)
131+
labels := sanitizeGeneratedConversationLabels(parseGeneratedConversationLabels(labelsOut.Text))
132+
if len(labels) == 0 {
133+
return
134+
}
135+
raw, marshalErr := json.Marshal(labels)
136+
if marshalErr != nil {
137+
setGenerateErr(marshalErr)
138+
return
139+
}
140+
mu.Lock()
141+
labelsJSON = string(raw)
142+
mu.Unlock()
143+
}()
144+
}
138145

139146
wg.Wait()
140147
mu.Lock()
@@ -388,6 +395,19 @@ func sanitizeGeneratedConversationTitle(raw string) string {
388395
return strings.TrimSpace(value)
389396
}
390397

398+
func conversationTitleFromFirstUserMessage(content string) string {
399+
value := strings.Join(strings.Fields(strings.TrimSpace(content)), " ")
400+
value = strings.Trim(value, " \t\r\n\"'`“”‘’")
401+
if value == "" {
402+
return ""
403+
}
404+
runes := []rune(value)
405+
if len(runes) > conversationFirstMessageTitleMaxRunes {
406+
value = string(runes[:conversationFirstMessageTitleMaxRunes])
407+
}
408+
return strings.TrimSpace(value)
409+
}
410+
391411
func sanitizeGeneratedConversationLabels(raw []string) []string {
392412
seen := make(map[string]struct{}, len(raw))
393413
labels := make([]string, 0, len(raw))
@@ -414,6 +434,17 @@ func sanitizeGeneratedConversationLabels(raw []string) []string {
414434
return labels
415435
}
416436

437+
func (s *Service) autoGenerateConversationTitleEnabled(ctx context.Context, userID uint) bool {
438+
value, err := s.repo.GetUserSettingValue(ctx, userID, conversationAutoGenerateTitleSettingKey)
439+
if err != nil {
440+
if s.logger != nil {
441+
s.logger.Warn("conversation_title_setting_load_failed", zap.Uint("user_id", userID), zap.Error(err))
442+
}
443+
return true
444+
}
445+
return strings.TrimSpace(strings.ToLower(value)) != "false"
446+
}
447+
417448
func shouldAutoReplaceConversationTitle(title string) bool {
418449
value := strings.TrimSpace(strings.ToLower(title))
419450
switch value {

backend/internal/application/conversation/service_metadata_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,17 @@ func TestParseGeneratedConversationTitleRejectsDirtyOutput(t *testing.T) {
5757
}
5858
}
5959
}
60+
61+
func TestConversationTitleFromFirstUserMessage(t *testing.T) {
62+
cases := map[string]string{
63+
" 这是一条很长的第一条用户消息,用来测试标题截断 ": "这是一条很长的第一条用户消息,用来测试标",
64+
"\n\nhello world from DEEIX\n": "hello world from DEE",
65+
"\"简短标题\"": "简短标题",
66+
" ": "",
67+
}
68+
for input, want := range cases {
69+
if got := conversationTitleFromFirstUserMessage(input); got != want {
70+
t.Fatalf("unexpected first-message title for %q: got %q, want %q", input, got, want)
71+
}
72+
}
73+
}

backend/internal/application/usersettings/service.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ var allowedKeys = map[string]string{
2727
"chat.show_latency": "true",
2828
"chat.show_billing_cost": "true",
2929
"chat.default_model": "",
30+
"chat.auto_generate_title": "true",
3031
"chat.context_compact_auto": "true",
3132
"chat.markdown_render": "true",
3233
"chat.restore_draft_on_failure": "true",
@@ -40,6 +41,7 @@ var boolKeys = map[string]bool{
4041
"chat.show_model_info": true,
4142
"chat.show_latency": true,
4243
"chat.show_billing_cost": true,
44+
"chat.auto_generate_title": true,
4345
"chat.context_compact_auto": true,
4446
"chat.markdown_render": true,
4547
"chat.restore_draft_on_failure": true,

frontend/features/chat/hooks/use-chat-message-submit.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,13 @@ function normalizeLabelsJSON(value: string | null | undefined): string {
130130
return normalized && normalized !== "null" ? normalized : "[]";
131131
}
132132

133-
function shouldRefreshGeneratedConversationMetadata(item: ConversationDTO | null): boolean {
134-
return item !== null && item.messageCount === 0;
133+
function isPlaceholderConversationTitle(title: string): boolean {
134+
const value = title.trim().toLowerCase();
135+
return ["", "new conversation", "untitled", "新会话", "新对话", "新的对话"].includes(value);
136+
}
137+
138+
function shouldRefreshGeneratedConversationMetadata(item: ConversationDTO | null, visibleMessageCount: number): boolean {
139+
return visibleMessageCount === 0 || item?.messageCount === 0;
135140
}
136141

137142
function hasGeneratedConversationMetadataChanged(
@@ -140,7 +145,7 @@ function hasGeneratedConversationMetadataChanged(
140145
): boolean {
141146
const previousTitle = previous?.title?.trim() ?? "";
142147
const nextTitle = next.title.trim();
143-
if (nextTitle && nextTitle !== previousTitle) {
148+
if (nextTitle && nextTitle !== previousTitle && !isPlaceholderConversationTitle(nextTitle)) {
144149
return true;
145150
}
146151
return normalizeLabelsJSON(next.labelsJSON) !== normalizeLabelsJSON(previous?.labelsJSON);
@@ -424,7 +429,7 @@ export function useChatMessageSubmit({
424429
window.history.replaceState(null, "", `/chat?conversation_id=${created.publicID}`);
425430
onConversationCreated?.(created.publicID);
426431
}
427-
const shouldRefreshConversationMetadata = shouldRefreshGeneratedConversationMetadata(targetConversation);
432+
const shouldRefreshConversationMetadata = shouldRefreshGeneratedConversationMetadata(targetConversation, visibleMessageCount);
428433

429434
const commonStreamPayload = {
430435
model: requestPlatformModelName,

frontend/features/settings/components/sections/settings-chat.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,19 @@ export function SettingsChat() {
456456
/>
457457
)}
458458
</SettingsFieldRow>
459+
<div className="pt-4">
460+
<SettingsFieldRow
461+
title={t("defaultModel.autoTitle")}
462+
description={t("defaultModel.autoTitleDescription")}
463+
>
464+
<Switch
465+
checked={settings.autoGenerateTitle}
466+
onCheckedChange={handleBool("chat.auto_generate_title", "autoGenerateTitle")}
467+
disabled={loading}
468+
aria-label={t("defaultModel.autoTitle")}
469+
/>
470+
</SettingsFieldRow>
471+
</div>
459472
</SettingsFieldList>
460473
</SettingsSection>
461474

frontend/features/settings/types/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type ChatSettings = {
1515
showLatency: boolean;
1616
showBillingCost: boolean;
1717
markdownRender: boolean;
18+
autoGenerateTitle: boolean;
1819
contextCompactAuto: boolean;
1920
restoreDraftOnFailure: boolean;
2021
preserveConversationDrafts: boolean;

frontend/features/settings/utils/chat-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
1515
showLatency: true,
1616
showBillingCost: true,
1717
markdownRender: true,
18+
autoGenerateTitle: true,
1819
contextCompactAuto: false,
1920
restoreDraftOnFailure: true,
2021
preserveConversationDrafts: true,
@@ -35,6 +36,7 @@ export function parseChatSettings(map: UserSettingsMap): ChatSettings {
3536
showLatency: map["chat.show_latency"] !== "false",
3637
showBillingCost: map["chat.show_billing_cost"] !== "false",
3738
markdownRender: map["chat.markdown_render"] !== "false",
39+
autoGenerateTitle: map["chat.auto_generate_title"] !== "false",
3840
contextCompactAuto: map["chat.context_compact_auto"] === "true",
3941
restoreDraftOnFailure: map["chat.restore_draft_on_failure"] !== "false",
4042
preserveConversationDrafts: map["chat.preserve_conversation_drafts"] !== "false",

frontend/i18n/messages/en-US/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@
346346
"sectionTitle": "Default model",
347347
"title": "Default model for new chats",
348348
"description": "The model preselected for new chats. Leave empty to use the first available system-recommended model.",
349-
"systemRecommended": "System default"
349+
"systemRecommended": "System default",
350+
"autoTitle": "Auto-generate titles",
351+
"autoTitleDescription": "When disabled, new chats use the first 20 characters of the first message as the title."
350352
},
351353
"input": {
352354
"sectionTitle": "Input and sending",

frontend/i18n/messages/zh-CN/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@
346346
"sectionTitle": "默认模型",
347347
"title": "新会话默认模型",
348348
"description": "开启新对话时预选的模型。留空则使用系统推荐的第一个可用模型。",
349-
"systemRecommended": "系统默认"
349+
"systemRecommended": "系统默认",
350+
"autoTitle": "自动生成标题",
351+
"autoTitleDescription": "关闭后,新对话使用第一条消息的前 20 个字符作为标题。"
350352
},
351353
"input": {
352354
"sectionTitle": "输入与发送",

0 commit comments

Comments
 (0)