webui: add option for LLM title generation (#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
This commit is contained in:
+2739
-2728
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -13,6 +13,7 @@
|
|||||||
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
|
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
|
||||||
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
|
import { TruncatedText } from '$lib/components/app';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -148,9 +149,7 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="truncate text-sm font-medium">
|
<TruncatedText text={conversation.name} class="text-sm font-medium" showTooltip={false} />
|
||||||
{conversation.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if renderActionsDropdown}
|
{#if renderActionsDropdown}
|
||||||
|
|||||||
+8
-6
@@ -104,13 +104,15 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if field.type === SettingsFieldType.TEXTAREA}
|
{:else if field.type === SettingsFieldType.TEXTAREA}
|
||||||
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
{#if field.label}
|
||||||
{field.label}
|
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
|
||||||
{#if field.isExperimental}
|
{#if field.isExperimental}
|
||||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id={field.key}
|
id={field.key}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export * from './settings-keys';
|
|||||||
export * from './settings-sections';
|
export * from './settings-sections';
|
||||||
export * from './supported-file-types';
|
export * from './supported-file-types';
|
||||||
export * from './table-html-restorer';
|
export * from './table-html-restorer';
|
||||||
|
export * from './title-generation';
|
||||||
export * from './tools';
|
export * from './tools';
|
||||||
export * from './tooltip-config';
|
export * from './tooltip-config';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ColorMode } from '$lib/enums/ui';
|
import { ColorMode } from '$lib/enums/ui';
|
||||||
import { Monitor, Moon, Sun } from '@lucide/svelte';
|
import { Monitor, Moon, Sun } from '@lucide/svelte';
|
||||||
|
import { TITLE } from './title-generation';
|
||||||
|
|
||||||
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean | undefined> = {
|
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean | undefined> = {
|
||||||
// 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.
|
// 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 |
|
|||||||
showMessageStats: true,
|
showMessageStats: true,
|
||||||
askForTitleConfirmation: false,
|
askForTitleConfirmation: false,
|
||||||
titleGenerationUseFirstLine: false,
|
titleGenerationUseFirstLine: false,
|
||||||
|
titleGenerationUseLLM: false,
|
||||||
|
titleGenerationPrompt: TITLE.DEFAULT_PROMPT,
|
||||||
pasteLongTextToFileLen: 2500,
|
pasteLongTextToFileLen: 2500,
|
||||||
copyTextAttachmentsAsPlainText: false,
|
copyTextAttachmentsAsPlainText: false,
|
||||||
pdfAsImage: false,
|
pdfAsImage: false,
|
||||||
@@ -121,6 +124,10 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
|||||||
'Ask for confirmation before automatically changing conversation title when editing the first message.',
|
'Ask for confirmation before automatically changing conversation title when editing the first message.',
|
||||||
titleGenerationUseFirstLine:
|
titleGenerationUseFirstLine:
|
||||||
'Use only the first non-empty line of the prompt to generate the conversation title.',
|
'Use only the first non-empty line of the prompt to generate the conversation title.',
|
||||||
|
titleGenerationUseLLM:
|
||||||
|
'Use the LLM to automatically generate conversation titles based on the first message exchange.',
|
||||||
|
titleGenerationPrompt:
|
||||||
|
'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
|
||||||
pdfAsImage:
|
pdfAsImage:
|
||||||
'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
|
'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
|
||||||
disableAutoScroll:
|
disableAutoScroll:
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const SETTINGS_KEYS = {
|
|||||||
PDF_AS_IMAGE: 'pdfAsImage',
|
PDF_AS_IMAGE: 'pdfAsImage',
|
||||||
ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
|
ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
|
||||||
TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
|
TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
|
||||||
|
TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM',
|
||||||
|
TITLE_GENERATION_PROMPT: 'titleGenerationPrompt',
|
||||||
// Display
|
// Display
|
||||||
SHOW_MESSAGE_STATS: 'showMessageStats',
|
SHOW_MESSAGE_STATS: 'showMessageStats',
|
||||||
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
||||||
|
|||||||
@@ -95,6 +95,17 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
|||||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||||
label: 'Use first non-empty line for conversation title',
|
label: 'Use first non-empty line for conversation title',
|
||||||
type: SettingsFieldType.CHECKBOX
|
type: SettingsFieldType.CHECKBOX
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||||
|
label: 'Use LLM to generate conversation title',
|
||||||
|
type: SettingsFieldType.CHECKBOX,
|
||||||
|
isExperimental: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
|
||||||
|
type: SettingsFieldType.TEXTAREA,
|
||||||
|
isExperimental: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/* Title generation constants */
|
||||||
|
export const TITLE = {
|
||||||
|
MIN_LENGTH: 3,
|
||||||
|
FALLBACK: 'New Chat',
|
||||||
|
DEFAULT_PROMPT:
|
||||||
|
'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:',
|
||||||
|
PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i,
|
||||||
|
QUOTE_PATTERN: /^["]|["]$/g
|
||||||
|
} as const;
|
||||||
@@ -14,11 +14,59 @@ import {
|
|||||||
ReasoningFormat,
|
ReasoningFormat,
|
||||||
UrlProtocol
|
UrlProtocol
|
||||||
} from '$lib/enums';
|
} from '$lib/enums';
|
||||||
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
|
import type {
|
||||||
|
ApiChatMessageContentPart,
|
||||||
|
ApiChatMessageData,
|
||||||
|
ApiChatCompletionToolCall
|
||||||
|
} from '$lib/types/api';
|
||||||
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||||
import { modelsStore } from '$lib/stores/models.svelte';
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Title Generation
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a streaming chat completion request for generating a chat title.
|
||||||
|
* Delegates to `sendMessage` for fetch, SSE parsing, and error handling.
|
||||||
|
*
|
||||||
|
* @param message - The single message to send (a user message containing the title generation prompt)
|
||||||
|
* @param model - Optional model name to use (required in ROUTER mode)
|
||||||
|
* @param signal - Optional AbortSignal to cancel the request
|
||||||
|
* @returns {Promise<string>} The aggregated title text, or empty string if request failed
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
static async generateTitle(
|
||||||
|
message: ApiChatMessageData,
|
||||||
|
model?: string | null,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<string> {
|
||||||
|
let titleResponse = '';
|
||||||
|
try {
|
||||||
|
await ChatService.sendMessage(
|
||||||
|
[message],
|
||||||
|
{
|
||||||
|
model: model || undefined,
|
||||||
|
stream: true,
|
||||||
|
custom: { chat_template_kwargs: { enable_thinking: false } },
|
||||||
|
onChunk: (chunk: string) => {
|
||||||
|
titleResponse += chunk;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return titleResponse;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
@@ -122,7 +170,11 @@ export class ChatService {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
// If only text remains and it's a single part, simplify to string
|
// If only text remains and it's a single part, simplify to string
|
||||||
if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
|
if (
|
||||||
|
msg.content.length === 1 &&
|
||||||
|
msg.content[0].type === ContentPartType.TEXT &&
|
||||||
|
typeof msg.content[0].text === 'string'
|
||||||
|
) {
|
||||||
msg.content = msg.content[0].text;
|
msg.content = msg.content[0].text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
MAX_INACTIVE_CONVERSATION_STATES,
|
MAX_INACTIVE_CONVERSATION_STATES,
|
||||||
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
|
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
|
||||||
SYSTEM_MESSAGE_PLACEHOLDER
|
SYSTEM_MESSAGE_PLACEHOLDER,
|
||||||
|
TITLE
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import type {
|
import type {
|
||||||
ChatMessageTimings,
|
ChatMessageTimings,
|
||||||
@@ -44,7 +45,12 @@ import type {
|
|||||||
ChatStreamCallbacks,
|
ChatStreamCallbacks,
|
||||||
ErrorDialogState
|
ErrorDialogState
|
||||||
} from '$lib/types/chat';
|
} from '$lib/types/chat';
|
||||||
import type { ApiProcessingState, DatabaseMessage, DatabaseMessageExtra } from '$lib/types';
|
import type {
|
||||||
|
ApiChatMessageData,
|
||||||
|
ApiProcessingState,
|
||||||
|
DatabaseMessage,
|
||||||
|
DatabaseMessageExtra
|
||||||
|
} from '$lib/types';
|
||||||
import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums';
|
import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums';
|
||||||
|
|
||||||
interface ConversationStateEntry {
|
interface ConversationStateEntry {
|
||||||
@@ -572,7 +578,11 @@ class ChatStore {
|
|||||||
conversationsStore.addMessageToActive(assistantMessage);
|
conversationsStore.addMessageToActive(assistantMessage);
|
||||||
await this.streamChatCompletion(
|
await this.streamChatCompletion(
|
||||||
conversationsStore.activeMessages.slice(0, -1),
|
conversationsStore.activeMessages.slice(0, -1),
|
||||||
assistantMessage
|
assistantMessage,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
config().titleGenerationUseLLM && isNewConversation ? content : undefined
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error)) {
|
||||||
@@ -601,7 +611,8 @@ class ChatStore {
|
|||||||
assistantMessage: DatabaseMessage,
|
assistantMessage: DatabaseMessage,
|
||||||
onComplete?: (content: string) => Promise<void>,
|
onComplete?: (content: string) => Promise<void>,
|
||||||
onError?: (error: Error) => void,
|
onError?: (error: Error) => void,
|
||||||
modelOverride?: string | null
|
modelOverride?: string | null,
|
||||||
|
firstUserMessageContent?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let effectiveModel = modelOverride;
|
let effectiveModel = modelOverride;
|
||||||
|
|
||||||
@@ -894,6 +905,12 @@ class ChatStore {
|
|||||||
if (onComplete) await onComplete(content);
|
if (onComplete) await onComplete(content);
|
||||||
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
|
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
|
||||||
|
|
||||||
|
// Generate LLM based title for new conversations (avoids stale reference
|
||||||
|
// issue when user switches conversations while streaming)
|
||||||
|
if (firstUserMessageContent) {
|
||||||
|
await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's a pending message queued during streaming
|
// Check if there's a pending message queued during streaming
|
||||||
const pending = this.consumePendingMessage(convId);
|
const pending = this.consumePendingMessage(convId);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -921,6 +938,49 @@ class ChatStore {
|
|||||||
this.setProcessingState(convId, null);
|
this.setProcessingState(convId, null);
|
||||||
this.clearPendingMessage(convId);
|
this.clearPendingMessage(convId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async generateTitleWithLLM(
|
||||||
|
userContent: string,
|
||||||
|
assistantContent: string,
|
||||||
|
convId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined;
|
||||||
|
const configValue = config();
|
||||||
|
const titlePromptTemplate =
|
||||||
|
typeof configValue.titleGenerationPrompt === 'string' &&
|
||||||
|
configValue.titleGenerationPrompt.trim()
|
||||||
|
? configValue.titleGenerationPrompt
|
||||||
|
: TITLE.DEFAULT_PROMPT;
|
||||||
|
|
||||||
|
const titlePrompt = titlePromptTemplate
|
||||||
|
.replace('{{USER}}', String(userContent || ''))
|
||||||
|
.replace('{{ASSISTANT}}', String(assistantContent || ''));
|
||||||
|
|
||||||
|
const titleMessage: ApiChatMessageData = {
|
||||||
|
role: MessageRole.USER,
|
||||||
|
content: titlePrompt
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel);
|
||||||
|
|
||||||
|
if (!titleResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanTitle = titleResponse.trim();
|
||||||
|
cleanTitle = cleanTitle
|
||||||
|
.replace(TITLE.PREFIX_PATTERN, '')
|
||||||
|
.replace(TITLE.QUOTE_PATTERN, '')
|
||||||
|
.trim();
|
||||||
|
if (!cleanTitle || cleanTitle.length < TITLE.MIN_LENGTH) {
|
||||||
|
const firstLine = userContent.split('\n').find((l) => l.trim().length > 0);
|
||||||
|
cleanTitle = firstLine ? firstLine.trim() : TITLE.FALLBACK;
|
||||||
|
}
|
||||||
|
if (cleanTitle && cleanTitle.length >= TITLE.MIN_LENGTH) {
|
||||||
|
await conversationsStore.updateConversationName(convId, cleanTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
|
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
|
||||||
const conversationId = convId || conversationsStore.activeConversation?.id;
|
const conversationId = convId || conversationsStore.activeConversation?.id;
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user