Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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]>;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<script lang="ts">
import { FlaskConical, RotateCcw } from '@lucide/svelte';
import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
import {
SettingsChatModelSelector,
SettingsChatParameterSourceIndicator
} from '$lib/components/app/settings';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
Expand Down Expand Up @@ -159,6 +162,29 @@
</Label>
</div>
{/if}
{:else if field.type === SettingsFieldType.MODEL_SELECT}
<Label class="flex items-center gap-1.5 text-sm font-medium" for={field.key}>
{field.label}

{#if field.isExperimental}
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
{/if}
</Label>

<SettingsChatModelSelector
emptyOption={field.emptyOption}
filter={field.modelFilter}
id={field.key}
onSelect={(model) => onConfigChange(field.key, model)}
placeholder={`Select ${field.label.toLowerCase()}`}
value={String(localConfig[field.key] ?? '')}
/>

{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === SettingsFieldType.SELECT}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts">
import { Check, ChevronDown } from '@lucide/svelte';
import { DropdownMenuSearchable, ModelsSelectorList } from '$lib/components/app';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { modelsStore } from '$lib/stores';

interface Props {
id: string;
onSelect: (model: string) => void;
placeholder: string;
value: string;
/** Entry above the model list that stores its own value, e.g. an automatic choice. */
emptyOption?: { label: string; value: string };
/** Restricts the model list, e.g. to audio-capable models. */
filter?: (model: ModelOption) => boolean;
}

let { emptyOption, filter, id, onSelect, placeholder, value }: Props = $props();

let open = $state(false);
let searchTerm = $state('');

let models = $derived(filter ? modelsStore.models.filter(filter) : modelsStore.models);
let filteredOptions = $derived(filterModelOptions(models, searchTerm));
let groups = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (model) =>
modelsStore.isModelLoaded(model)
)
);
// The stored value survives a model leaving the router list, so the raw
// id is shown when no option matches it.
let triggerLabel = $derived.by(() => {
if (emptyOption && value === emptyOption.value) return emptyOption.label;

return modelsStore.models.find((m) => m.model === value)?.name || value || placeholder;
});

function handleOpenChange(next: boolean) {
open = next;
searchTerm = '';
}

function handleSelect(modelId: string) {
const option = modelsStore.models.find((m) => m.id === modelId);

if (!option) return;

onSelect(option.model);
handleOpenChange(false);
}
</script>

<DropdownMenu.Root onOpenChange={handleOpenChange} {open}>
<DropdownMenu.Trigger {id}>
{#snippet child({ props })}
<Button {...props} class="w-full justify-between font-normal" variant="outline">
<span class="truncate">{triggerLabel}</span>

<ChevronDown class="h-4 w-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</DropdownMenu.Trigger>

<DropdownMenu.Content
class="w-100 max-w-[calc(100vw-2rem)] pt-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
bind:searchValue={searchTerm}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && !emptyOption}
placeholder="Search models..."
>
<div class="max-h-72 overflow-y-auto">
{#if emptyOption}
<DropdownMenu.Item
class="flex items-center justify-between"
onclick={() => onSelect(emptyOption.value)}
>
<span class="truncate">{emptyOption.label}</span>

{#if value === emptyOption.value}
<Check class="h-4 w-4 shrink-0" />
{/if}
</DropdownMenu.Item>
{/if}

<ModelsSelectorList activeId={null} currentModel={value} {groups} onSelect={handleSelect} />
</div>
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
7 changes: 7 additions & 0 deletions tools/ui/src/lib/components/app/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tools/ui/src/lib/constants/settings-keys.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions tools/ui/src/lib/constants/settings.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tools/ui/src/lib/constants/title-generation.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions tools/ui/src/lib/enums/settings.enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum SyncableParameterType {
export enum SettingsFieldType {
CHECKBOX = 'checkbox',
INPUT = 'input',
MODEL_SELECT = 'model_select',
RADIO = 'radio',
SELECT = 'select',
TEXTAREA = 'textarea'
Expand Down
5 changes: 1 addition & 4 deletions tools/ui/src/lib/stores/chat/index.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1276,11 +1276,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
assistantContent: string,
convId: string
): Promise<void> {
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()
Expand Down
22 changes: 21 additions & 1 deletion tools/ui/src/lib/stores/models/index.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
* {@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
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';
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions tools/ui/src/lib/types/settings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }>;
}
Expand Down