diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte index e40e33d0c087..3d413f5d2b76 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte @@ -10,7 +10,7 @@ sectionHeaderClass?: string; orgHeaderClass?: string; onSelect: (modelId: string) => void; - onInfoClick: (modelName: string) => void; + onInfoClick?: (modelName: string) => void; renderOption?: import('svelte').Snippet<[ModelItem, boolean]>; } diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte index e8795d7c5204..5bda6cfaec3c 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte @@ -1,6 +1,9 @@ + + + + {#snippet child({ props })} + + {triggerLabel} + + + + {/snippet} + + + event.preventDefault()} + > + + + {#if emptyOption} + onSelect(emptyOption.value)} + > + {emptyOption.label} + + {#if value === emptyOption.value} + + {/if} + + {/if} + + + + + + diff --git a/tools/ui/src/lib/components/app/settings/index.ts b/tools/ui/src/lib/components/app/settings/index.ts index 7473e6af8e42..d40c1040d0ff 100644 --- a/tools/ui/src/lib/components/app/settings/index.ts +++ b/tools/ui/src/lib/components/app/settings/index.ts @@ -19,6 +19,13 @@ export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSide */ export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte'; +/** + * Model picker for settings fields. Shares the search, grouping and option + * rendering of the chat model selector, and writes the chosen model back + * through `onSelect`. + */ +export { default as SettingsChatModelSelector } from './SettingsChat/SettingsChatModelSelector.svelte'; + /** * Badge indicating parameter source for sampling settings. Shows one of: * - **Custom**: User has explicitly set this value (orange badge) diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts index 2c923cee385d..025cc47f12d2 100644 --- a/tools/ui/src/lib/constants/settings-keys.constants.ts +++ b/tools/ui/src/lib/constants/settings-keys.constants.ts @@ -67,6 +67,7 @@ export const SETTINGS_KEYS = { TEMPERATURE: 'temperature', // General THEME: 'theme', + TITLE_GENERATION_MODEL: 'titleGenerationModel', TITLE_GENERATION_PROMPT: 'titleGenerationPrompt', TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine', TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM', diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts index 7140bbcc4a1a..0b8432973ecf 100644 --- a/tools/ui/src/lib/constants/settings.constants.ts +++ b/tools/ui/src/lib/constants/settings.constants.ts @@ -153,6 +153,18 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ label: 'LLM title generation prompt', type: SettingsFieldType.TEXTAREA }, + { + defaultValue: TITLE_GENERATION.MODEL_AUTO, + dependsOn: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM, + emptyOption: { + label: 'Auto (model of the conversation)', + value: TITLE_GENERATION.MODEL_AUTO + }, + help: 'Model that writes the title. Pick a smaller one to keep the chat model free. Router mode only.', + key: SETTINGS_KEYS.TITLE_GENERATION_MODEL, + label: 'LLM title generation model', + type: SettingsFieldType.MODEL_SELECT + }, { defaultValue: false, help: 'Counterpart of the conversation title radio; stored and synced without a dedicated UI field.', @@ -665,6 +677,7 @@ function toSettingsSection(section: SettingsSectionEntry): SettingsSection { .filter((s) => s.standaloneField !== false) .map((s) => ({ dependsOn: s.dependsOn, + emptyOption: s.emptyOption, help: s.help, isExperimental: s.isExperimental, isPositiveInteger: s.isPositiveInteger, @@ -673,6 +686,7 @@ function toSettingsSection(section: SettingsSectionEntry): SettingsSection { label: s.label, max: s.max, min: s.min, + modelFilter: s.modelFilter, options: s.options as SettingsFieldConfig['options'], placeholder: s.placeholder, radioOptions: s.radioOptions, diff --git a/tools/ui/src/lib/constants/title-generation.constants.ts b/tools/ui/src/lib/constants/title-generation.constants.ts index 0496daafe2c8..656a6e7aeadf 100644 --- a/tools/ui/src/lib/constants/title-generation.constants.ts +++ b/tools/ui/src/lib/constants/title-generation.constants.ts @@ -4,6 +4,8 @@ export const TITLE_GENERATION = { '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:', FALLBACK: 'New Chat', MIN_LENGTH: 3, + // Title generation model setting value that keeps the conversation model. + MODEL_AUTO: 'auto', PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i, QUOTE_PATTERN: /^["]|["]$/g } as const; diff --git a/tools/ui/src/lib/enums/settings.enums.ts b/tools/ui/src/lib/enums/settings.enums.ts index 9911670b37bc..aa672eab163d 100644 --- a/tools/ui/src/lib/enums/settings.enums.ts +++ b/tools/ui/src/lib/enums/settings.enums.ts @@ -21,6 +21,7 @@ export enum SyncableParameterType { export enum SettingsFieldType { CHECKBOX = 'checkbox', INPUT = 'input', + MODEL_SELECT = 'model_select', RADIO = 'radio', SELECT = 'select', TEXTAREA = 'textarea' diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts index 4bdcc6845668..63f730743560 100644 --- a/tools/ui/src/lib/stores/chat/index.svelte.ts +++ b/tools/ui/src/lib/stores/chat/index.svelte.ts @@ -1276,11 +1276,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { assistantContent: string, convId: string ): Promise { - const effectiveModel = - serverStore.isRouterMode && modelsStore.selectedModelName - ? modelsStore.selectedModelName - : undefined; const configValue = settingsStore.config; + const effectiveModel = modelsStore.titleModelName ?? undefined; const titlePromptTemplate = typeof configValue.titleGenerationPrompt === 'string' && configValue.titleGenerationPrompt.trim() diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts index 90d6fe76b719..db5fb15ddc7b 100644 --- a/tools/ui/src/lib/stores/models/index.svelte.ts +++ b/tools/ui/src/lib/stores/models/index.svelte.ts @@ -7,7 +7,7 @@ * {@link ModelsStore.status}; tracks which conversations use which models. */ -import { FAVORITE_MODELS_LOCALSTORAGE_KEY } from '$lib/constants'; +import { FAVORITE_MODELS_LOCALSTORAGE_KEY, TITLE_GENERATION } from '$lib/constants'; import { ServerModelStatus } from '$lib/enums'; import { ModelsService } from '$lib/services/models.service'; // direct imports between stores, not via the barrel, to avoid circular deps @@ -15,6 +15,7 @@ import { conversationsStore } from '$lib/stores/conversations/index.svelte'; import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte'; import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte'; import { serverStore } from '$lib/stores/server.svelte'; +import { settingsStore } from '$lib/stores/settings/index.svelte'; import { getConversationModel } from '$lib/utils/conversation-utils'; import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; @@ -114,6 +115,25 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { return this._status; } + /** + * Model that writes conversation titles (ROUTER mode only). The + * titleGenerationModel setting wins when that model is in the list, else + * the model the conversation runs on. + */ + get titleModelName(): string | null { + if (!serverStore.isRouterMode) return null; + + const preferred = settingsStore.config.titleGenerationModel; + + if (typeof preferred === 'string' && preferred !== TITLE_GENERATION.MODEL_AUTO) { + const model = this.models.find((m) => m.model === preferred); + + if (model) return model.model; + } + + return this.selectedModelName; + } + clearSelection(): void { this.selectedModelId = null; this.selectedModelName = null; diff --git a/tools/ui/src/lib/types/settings.d.ts b/tools/ui/src/lib/types/settings.d.ts index 377a570408d6..bad32f93b456 100644 --- a/tools/ui/src/lib/types/settings.d.ts +++ b/tools/ui/src/lib/types/settings.d.ts @@ -26,6 +26,10 @@ export interface SettingsEntry { defaultValue: SettingsConfigValue; type: SettingsFieldType; options?: Array<{ value: string; label: string; icon: Component }>; + /** For MODEL_SELECT fields: entry above the model list that stores its own value, e.g. an automatic choice. */ + emptyOption?: { label: string; value: string }; + /** For MODEL_SELECT fields: restricts the model list, e.g. to audio-capable models. */ + modelFilter?: (model: ModelOption) => boolean; /** Options rendered for RADIO fields. Each entry maps a `value` (the radio's selected value) to the underlying config `key` whose boolean state mirrors it. */ radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>; isExperimental?: boolean; @@ -65,6 +69,10 @@ export interface SettingsFieldConfig { dependsOn?: string; help?: string; options?: Array<{ value: string; label: string; icon?: typeof Icon }>; + /** For MODEL_SELECT fields: entry above the model list that stores its own value, e.g. an automatic choice. */ + emptyOption?: { label: string; value: string }; + /** For MODEL_SELECT fields: restricts the model list, e.g. to audio-capable models. */ + modelFilter?: (model: ModelOption) => boolean; /** Options rendered for RADIO fields. Each entry maps a `value` (the radio's selected value) to the underlying config `key` whose boolean state mirrors it. */ radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>; }