Skip to content

Commit aaf4a4d

Browse files
authored
webui: add option for LLM title generation (ggml-org#22265)
* webui: add LLM title generation option * webui: use chat_template_kwargs for title gen + fix conversation check * webui: capture firstUserMessage before async streamChatCompletion to fix race condition * webui: extract LLM title generation into separate method * webui: use constants and ChatService for LLM generated titles * webui: rebuild static output * webui: add LLM title generation setting to new settings location * webui: use sendMessage in generateTitle * webui: rebuild static output * webui: fix formatting * webui: configurable title prompt, remove think tag regexes, fix TS error * webui: group title constants into TITLE object, use TruncatedText for CSS truncation and fix race condition * webui: rebuild static output
1 parent e43431b commit aaf4a4d

10 files changed

Lines changed: 2897 additions & 2743 deletions

File tree

tools/server/public/bundle.js

Lines changed: 2739 additions & 2728 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/server/webui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
1414
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
1515
import { conversationsStore } from '$lib/stores/conversations.svelte';
16+
import { TruncatedText } from '$lib/components/app';
1617
import { onMount } from 'svelte';
1718
1819
interface Props {
@@ -148,9 +149,7 @@
148149
</Tooltip.Root>
149150
{/if}
150151

151-
<span class="truncate text-sm font-medium">
152-
{conversation.name}
153-
</span>
152+
<TruncatedText text={conversation.name} class="text-sm font-medium" showTooltip={false} />
154153
</div>
155154

156155
{#if renderActionsDropdown}

tools/server/webui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,15 @@
104104
</p>
105105
{/if}
106106
{:else if field.type === SettingsFieldType.TEXTAREA}
107-
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
108-
{field.label}
107+
{#if field.label}
108+
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
109+
{field.label}
109110

110-
{#if field.isExperimental}
111-
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
112-
{/if}
113-
</Label>
111+
{#if field.isExperimental}
112+
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
113+
{/if}
114+
</Label>
115+
{/if}
114116

115117
<Textarea
116118
id={field.key}

tools/server/webui/src/lib/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export * from './settings-keys';
3535
export * from './settings-sections';
3636
export * from './supported-file-types';
3737
export * from './table-html-restorer';
38+
export * from './title-generation';
3839
export * from './tools';
3940
export * from './tooltip-config';
4041
export * from './ui';

tools/server/webui/src/lib/constants/settings-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ColorMode } from '$lib/enums/ui';
22
import { Monitor, Moon, Sun } from '@lucide/svelte';
3+
import { TITLE } from './title-generation';
34

45
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean | undefined> = {
56
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value.
@@ -16,6 +17,8 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean |
1617
showMessageStats: true,
1718
askForTitleConfirmation: false,
1819
titleGenerationUseFirstLine: false,
20+
titleGenerationUseLLM: false,
21+
titleGenerationPrompt: TITLE.DEFAULT_PROMPT,
1922
pasteLongTextToFileLen: 2500,
2023
copyTextAttachmentsAsPlainText: false,
2124
pdfAsImage: false,
@@ -121,6 +124,10 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
121124
'Ask for confirmation before automatically changing conversation title when editing the first message.',
122125
titleGenerationUseFirstLine:
123126
'Use only the first non-empty line of the prompt to generate the conversation title.',
127+
titleGenerationUseLLM:
128+
'Use the LLM to automatically generate conversation titles based on the first message exchange.',
129+
titleGenerationPrompt:
130+
'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
124131
pdfAsImage:
125132
'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
126133
disableAutoScroll:

tools/server/webui/src/lib/constants/settings-keys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const SETTINGS_KEYS = {
1616
PDF_AS_IMAGE: 'pdfAsImage',
1717
ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
1818
TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
19+
TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM',
20+
TITLE_GENERATION_PROMPT: 'titleGenerationPrompt',
1921
// Display
2022
SHOW_MESSAGE_STATS: 'showMessageStats',
2123
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',

tools/server/webui/src/lib/constants/settings-sections.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
9595
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
9696
label: 'Use first non-empty line for conversation title',
9797
type: SettingsFieldType.CHECKBOX
98+
},
99+
{
100+
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
101+
label: 'Use LLM to generate conversation title',
102+
type: SettingsFieldType.CHECKBOX,
103+
isExperimental: true
104+
},
105+
{
106+
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
107+
type: SettingsFieldType.TEXTAREA,
108+
isExperimental: true
98109
}
99110
]
100111
},
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* Title generation constants */
2+
export const TITLE = {
3+
MIN_LENGTH: 3,
4+
FALLBACK: 'New Chat',
5+
DEFAULT_PROMPT:
6+
'Based on the following interaction, generate a short, concise title (maximum 6-8 words) that captures the main topic. Return ONLY the title text, nothing else. Do not use quotes.\n\nUser: {{USER}}\n\nAssistant: {{ASSISTANT}}\n\nTitle:',
7+
PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i,
8+
QUOTE_PATTERN: /^["]|["]$/g
9+
} as const;

tools/server/webui/src/lib/services/chat.service.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,59 @@ import {
1414
ReasoningFormat,
1515
UrlProtocol
1616
} from '$lib/enums';
17-
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
17+
import type {
18+
ApiChatMessageContentPart,
19+
ApiChatMessageData,
20+
ApiChatCompletionToolCall
21+
} from '$lib/types/api';
1822
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
1923
import { modelsStore } from '$lib/stores/models.svelte';
2024

2125
export class ChatService {
26+
/**
27+
*
28+
*
29+
* Title Generation
30+
*
31+
*
32+
*/
33+
34+
/**
35+
* Sends a streaming chat completion request for generating a chat title.
36+
* Delegates to `sendMessage` for fetch, SSE parsing, and error handling.
37+
*
38+
* @param message - The single message to send (a user message containing the title generation prompt)
39+
* @param model - Optional model name to use (required in ROUTER mode)
40+
* @param signal - Optional AbortSignal to cancel the request
41+
* @returns {Promise<string>} The aggregated title text, or empty string if request failed
42+
* @static
43+
*/
44+
static async generateTitle(
45+
message: ApiChatMessageData,
46+
model?: string | null,
47+
signal?: AbortSignal
48+
): Promise<string> {
49+
let titleResponse = '';
50+
try {
51+
await ChatService.sendMessage(
52+
[message],
53+
{
54+
model: model || undefined,
55+
stream: true,
56+
custom: { chat_template_kwargs: { enable_thinking: false } },
57+
onChunk: (chunk: string) => {
58+
titleResponse += chunk;
59+
}
60+
},
61+
undefined,
62+
signal
63+
);
64+
} catch {
65+
return '';
66+
}
67+
return titleResponse;
68+
}
69+
2270
/**
2371
*
2472
*
@@ -122,7 +170,11 @@ export class ChatService {
122170
return true;
123171
});
124172
// If only text remains and it's a single part, simplify to string
125-
if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
173+
if (
174+
msg.content.length === 1 &&
175+
msg.content[0].type === ContentPartType.TEXT &&
176+
typeof msg.content[0].text === 'string'
177+
) {
126178
msg.content = msg.content[0].text;
127179
}
128180
}

tools/server/webui/src/lib/stores/chat.svelte.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,21 @@ import {
3636
import {
3737
MAX_INACTIVE_CONVERSATION_STATES,
3838
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
39-
SYSTEM_MESSAGE_PLACEHOLDER
39+
SYSTEM_MESSAGE_PLACEHOLDER,
40+
TITLE
4041
} from '$lib/constants';
4142
import type {
4243
ChatMessageTimings,
4344
ChatMessagePromptProgress,
4445
ChatStreamCallbacks,
4546
ErrorDialogState
4647
} from '$lib/types/chat';
47-
import type { ApiProcessingState, DatabaseMessage, DatabaseMessageExtra } from '$lib/types';
48+
import type {
49+
ApiChatMessageData,
50+
ApiProcessingState,
51+
DatabaseMessage,
52+
DatabaseMessageExtra
53+
} from '$lib/types';
4854
import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums';
4955

5056
interface ConversationStateEntry {
@@ -572,7 +578,11 @@ class ChatStore {
572578
conversationsStore.addMessageToActive(assistantMessage);
573579
await this.streamChatCompletion(
574580
conversationsStore.activeMessages.slice(0, -1),
575-
assistantMessage
581+
assistantMessage,
582+
undefined,
583+
undefined,
584+
undefined,
585+
config().titleGenerationUseLLM && isNewConversation ? content : undefined
576586
);
577587
} catch (error) {
578588
if (isAbortError(error)) {
@@ -601,7 +611,8 @@ class ChatStore {
601611
assistantMessage: DatabaseMessage,
602612
onComplete?: (content: string) => Promise<void>,
603613
onError?: (error: Error) => void,
604-
modelOverride?: string | null
614+
modelOverride?: string | null,
615+
firstUserMessageContent?: string
605616
): Promise<void> {
606617
let effectiveModel = modelOverride;
607618

@@ -894,6 +905,12 @@ class ChatStore {
894905
if (onComplete) await onComplete(content);
895906
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
896907

908+
// Generate LLM based title for new conversations (avoids stale reference
909+
// issue when user switches conversations while streaming)
910+
if (firstUserMessageContent) {
911+
await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
912+
}
913+
897914
// Check if there's a pending message queued during streaming
898915
const pending = this.consumePendingMessage(convId);
899916
if (pending) {
@@ -921,6 +938,49 @@ class ChatStore {
921938
this.setProcessingState(convId, null);
922939
this.clearPendingMessage(convId);
923940
}
941+
942+
private async generateTitleWithLLM(
943+
userContent: string,
944+
assistantContent: string,
945+
convId: string
946+
): Promise<void> {
947+
const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined;
948+
const configValue = config();
949+
const titlePromptTemplate =
950+
typeof configValue.titleGenerationPrompt === 'string' &&
951+
configValue.titleGenerationPrompt.trim()
952+
? configValue.titleGenerationPrompt
953+
: TITLE.DEFAULT_PROMPT;
954+
955+
const titlePrompt = titlePromptTemplate
956+
.replace('{{USER}}', String(userContent || ''))
957+
.replace('{{ASSISTANT}}', String(assistantContent || ''));
958+
959+
const titleMessage: ApiChatMessageData = {
960+
role: MessageRole.USER,
961+
content: titlePrompt
962+
};
963+
964+
const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel);
965+
966+
if (!titleResponse) {
967+
return;
968+
}
969+
970+
let cleanTitle = titleResponse.trim();
971+
cleanTitle = cleanTitle
972+
.replace(TITLE.PREFIX_PATTERN, '')
973+
.replace(TITLE.QUOTE_PATTERN, '')
974+
.trim();
975+
if (!cleanTitle || cleanTitle.length < TITLE.MIN_LENGTH) {
976+
const firstLine = userContent.split('\n').find((l) => l.trim().length > 0);
977+
cleanTitle = firstLine ? firstLine.trim() : TITLE.FALLBACK;
978+
}
979+
if (cleanTitle && cleanTitle.length >= TITLE.MIN_LENGTH) {
980+
await conversationsStore.updateConversationName(convId, cleanTitle);
981+
}
982+
}
983+
924984
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
925985
const conversationId = convId || conversationsStore.activeConversation?.id;
926986
if (!conversationId) return;

0 commit comments

Comments
 (0)