diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index dfaad2e94c..d7c51cfecf 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -30,6 +30,7 @@ import { getDefaultModelSpec, getDefaultEndpoint, getModelSpecPreset, + hasModelSelection, buildDefaultConvo, logger, } from '~/utils'; @@ -321,16 +322,15 @@ const useNewConvo = (index = 0) => { let preset = _preset; const result = getDefaultModelSpec(startupConfig); - const defaultModelSpec = result?.default ?? result?.last; - if ( - !preset && - startupConfig && - (startupConfig.modelSpecs?.prioritize === true || - (startupConfig.interface?.modelSelect ?? true) !== true || - (result?.last != null && - Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0)) && - defaultModelSpec - ) { + const defaultModelSpec = result?.default ?? result?.last ?? result?.softDefault; + const shouldApplyModelSpec = + result?.softDefault != null + ? !hasModelSelection(_template) + : startupConfig?.modelSpecs?.prioritize === true || + (startupConfig?.interface?.modelSelect ?? true) !== true || + (result?.last != null && + Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0); + if (!preset && startupConfig && shouldApplyModelSpec && defaultModelSpec) { preset = getModelSpecPreset(defaultModelSpec); } diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index 65eb82a40e..eebbc88106 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react'; -import { useRecoilCallback, useRecoilValue } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; +import { useRecoilCallback, useRecoilValue } from 'recoil'; import { Spinner, useToastContext } from '@librechat/client'; import { useParams, useSearchParams } from 'react-router-dom'; import { Constants, EModelEndpoint } from 'librechat-data-provider'; @@ -16,6 +16,12 @@ import { logger, clearMessagesCache, } from '~/utils'; +import { + useGetConvoIdQuery, + useGetStartupConfig, + useGetEndpointsQuery, + useProjectQuery, +} from '~/data-provider'; import { useAssistantListMap, useIdChangeEffect, @@ -23,12 +29,6 @@ import { useNewConvo, useLocalize, } from '~/hooks'; -import { - useGetConvoIdQuery, - useGetStartupConfig, - useGetEndpointsQuery, - useProjectQuery, -} from '~/data-provider'; import { ToolCallsMapProvider } from '~/Providers'; import ChatView from '~/components/Chat/ChatView'; import { NotificationSeverity } from '~/common'; @@ -167,7 +167,7 @@ export default function ChatRoute() { const getNewConvoPreset = () => { const result = getDefaultModelSpec(startupConfig); - const spec = result?.default ?? result?.last; + const spec = result?.default ?? result?.last ?? result?.softDefault; const specPreset = spec ? getModelSpecPreset(spec) : undefined; const queryParams: Record = {}; @@ -213,7 +213,7 @@ export default function ChatRoute() { isNotFoundError(initialConvoQuery.error) ) { const result = getDefaultModelSpec(startupConfig); - const spec = result?.default ?? result?.last; + const spec = result?.default ?? result?.last ?? result?.softDefault; showToast({ message: localize('com_ui_conversation_not_found'), severity: NotificationSeverity.WARNING, diff --git a/client/src/utils/__tests__/getDefaultModelSpec.test.ts b/client/src/utils/__tests__/getDefaultModelSpec.test.ts new file mode 100644 index 0000000000..1127399d2b --- /dev/null +++ b/client/src/utils/__tests__/getDefaultModelSpec.test.ts @@ -0,0 +1,90 @@ +import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider'; +import type { TModelSpec, TStartupConfig } from 'librechat-data-provider'; +import { getDefaultModelSpec } from '../endpoints'; + +const createModelSpec = (name: string, overrides: Partial = {}): TModelSpec => + ({ + name, + label: name, + preset: { + endpoint: EModelEndpoint.openAI, + model: name, + }, + ...overrides, + }) as TModelSpec; + +const createStartupConfig = (list: TModelSpec[]): TStartupConfig => + ({ + interface: { + modelSelect: true, + }, + modelSpecs: { + prioritize: true, + list, + }, + }) as TStartupConfig; + +describe('getDefaultModelSpec', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('uses the soft default for a fresh user with no prior selection', () => { + const regularSpec = createModelSpec('regular-spec'); + const softSpec = createModelSpec('soft-spec', { softDefault: true }); + + const result = getDefaultModelSpec(createStartupConfig([regularSpec, softSpec])); + + expect(result).toEqual({ softDefault: softSpec }); + }); + + it('keeps the last selected spec before applying the soft default', () => { + const lastSpec = createModelSpec('last-spec'); + const softSpec = createModelSpec('soft-spec', { softDefault: true }); + localStorage.setItem(LocalStorageKeys.LAST_SPEC, lastSpec.name); + + const result = getDefaultModelSpec(createStartupConfig([softSpec, lastSpec])); + + expect(result).toEqual({ last: lastSpec }); + }); + + it('does not apply the soft default when a prior model selection exists', () => { + const softSpec = createModelSpec('soft-spec', { softDefault: true }); + localStorage.setItem( + LocalStorageKeys.LAST_MODEL, + JSON.stringify({ [EModelEndpoint.openAI]: 'gpt-4o' }), + ); + + const result = getDefaultModelSpec(createStartupConfig([softSpec])); + + expect(result).toBeUndefined(); + }); + + it('does not apply the soft default when a prior agent selection exists', () => { + const softSpec = createModelSpec('soft-spec', { softDefault: true }); + localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, 'agent_123'); + + const result = getDefaultModelSpec(createStartupConfig([softSpec])); + + expect(result).toBeUndefined(); + }); + + it('keeps hard admin defaults ahead of user history and soft defaults', () => { + const hardSpec = createModelSpec('hard-spec', { default: true }); + const softSpec = createModelSpec('soft-spec', { softDefault: true }); + localStorage.setItem(LocalStorageKeys.LAST_SPEC, softSpec.name); + + const result = getDefaultModelSpec(createStartupConfig([softSpec, hardSpec])); + + expect(result).toEqual({ default: hardSpec }); + }); + + it('preserves the legacy first-spec fallback when no soft default is configured', () => { + const firstSpec = createModelSpec('first-spec'); + const secondSpec = createModelSpec('second-spec'); + + const result = getDefaultModelSpec(createStartupConfig([firstSpec, secondSpec])); + + expect(result).toEqual({ default: firstSpec }); + }); +}); diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 1d922744eb..03923c3289 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -137,6 +137,95 @@ interface InitiatedTemplateResult { newEndpointType: EModelEndpoint | undefined; } +type StoredModelSelection = Pick< + t.TConversation, + 'endpoint' | 'model' | 'spec' | 'agent_id' | 'assistant_id' +>; + +function hasSelectionValue(value?: string | null): boolean { + return typeof value === 'string' && value.trim() !== ''; +} + +function parseStoredModelSelection( + value: string | null, +): Partial | undefined { + if (!value) { + return; + } + + try { + return JSON.parse(value) as Partial; + } catch { + return; + } +} + +function hasStoredPrefixValue(prefix: string): boolean { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(prefix)) { + continue; + } + + if (hasSelectionValue(localStorage.getItem(key))) { + return true; + } + } + + return false; +} + +function hasStoredModelValue(): boolean { + const storedModelValue = localStorage.getItem(LocalStorageKeys.LAST_MODEL); + if (!storedModelValue) { + return false; + } + + try { + const storedModels = JSON.parse(storedModelValue) as Record; + return Object.values(storedModels).some(hasSelectionValue); + } catch { + return false; + } +} + +export function hasModelSelection(selection?: Partial | null): boolean { + if (!selection) { + return false; + } + + return ( + hasSelectionValue(selection.spec) || + hasSelectionValue(selection.agent_id) || + hasSelectionValue(selection.assistant_id) || + hasSelectionValue(selection.model) || + hasSelectionValue(selection.endpoint) + ); +} + +function hasStoredModelSelection(): boolean { + if (hasSelectionValue(localStorage.getItem(LocalStorageKeys.LAST_SPEC))) { + return true; + } + + if (hasStoredModelValue()) { + return true; + } + + if ( + hasStoredPrefixValue(LocalStorageKeys.AGENT_ID_PREFIX) || + hasStoredPrefixValue(LocalStorageKeys.ASST_ID_PREFIX) + ) { + return true; + } + + const lastConversationSetup = parseStoredModelSelection( + localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'), + ); + + return hasModelSelection(lastConversationSetup); +} + /** Get the conditional logic for switching conversations */ export function getConvoSwitchLogic(params: ConversationInitParams): InitiatedTemplateResult { const { conversation, newEndpoint, endpointsConfig, modularChat = false } = params; @@ -269,13 +358,14 @@ export function applyModelSpecEphemeralAgent({ /** * Gets default model spec from config and user preferences. - * Priority: admin default → last selected → first spec (when prioritize=true or modelSelect disabled). - * Otherwise: admin default or last conversation spec. + * Priority: hard admin default → prior user selection → soft first-time default. + * Legacy first-spec prioritization remains only when no soft default is configured. */ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig): | { default?: t.TModelSpec; last?: t.TModelSpec; + softDefault?: t.TModelSpec; } | undefined { const { modelSpecs, interface: interfaceConfig } = startupConfig ?? {}; @@ -284,20 +374,34 @@ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig): return; } const defaultSpec = list?.find((spec) => spec.default); + const softDefaultSpec = list?.find((spec) => spec.softDefault); if (prioritize === true || !interfaceConfig?.modelSelect) { const lastSelectedSpecName = localStorage.getItem(LocalStorageKeys.LAST_SPEC); const lastSelectedSpec = list?.find((spec) => spec.name === lastSelectedSpecName); - return { default: defaultSpec || lastSelectedSpec || list?.[0] }; + if (defaultSpec) { + return { default: defaultSpec }; + } + if (lastSelectedSpec) { + return { last: lastSelectedSpec }; + } + if (softDefaultSpec) { + return hasStoredModelSelection() ? undefined : { softDefault: softDefaultSpec }; + } + return { default: list?.[0] }; } else if (defaultSpec) { return { default: defaultSpec }; } - const lastConversationSetup = JSON.parse( - localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0') ?? '{}', + const lastConversationSetup = parseStoredModelSelection( + localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'), ); - if (!lastConversationSetup.spec) { + const lastConversationSpecName = lastConversationSetup?.spec; + if (!hasSelectionValue(lastConversationSpecName)) { + if (softDefaultSpec && !hasStoredModelSelection()) { + return { softDefault: softDefaultSpec }; + } return; } - return { last: list?.find((spec) => spec.name === lastConversationSetup.spec) }; + return { last: list?.find((spec) => spec.name === lastConversationSpecName) }; } export function getModelSpecPreset(modelSpec?: t.TModelSpec) { diff --git a/librechat.example.yaml b/librechat.example.yaml index 2f94889239..748a2a1ab1 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -606,6 +606,8 @@ endpoints: # - name: "gpt-4o" # label: "GPT-4 Optimized" # description: "Most capable GPT-4 model with multimodal support" +# # default: true # Hard admin default; takes precedence over prior user choices +# # softDefault: true # First-time default only; skipped after a user selects a model/spec/agent # group: "openAI" # String value matching the endpoint name # preset: # endpoint: "openAI" diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 33ee6db279..3a63fb3b52 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -836,6 +836,7 @@ describe('specsConfigSchema', () => { name: 'spec-1', label: 'Spec 1', hideBadgeRow: true, + softDefault: true, preset: { endpoint: EModelEndpoint.openAI }, }, ], @@ -843,6 +844,7 @@ describe('specsConfigSchema', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.list[0].hideBadgeRow).toBe(true); + expect(result.data.list[0].softDefault).toBe(true); } }); diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index 5b7274401c..430f228bbf 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -14,6 +14,7 @@ export type TModelSpec = { preset: TModelSpecPreset; order?: number; default?: boolean; + softDefault?: boolean; description?: string; /** * Optional group name for organizing specs in the UI selector. @@ -48,6 +49,7 @@ export const tModelSpecSchema = z.object({ preset: tModelSpecPresetSchema, order: z.number().optional(), default: z.boolean().optional(), + softDefault: z.boolean().optional(), description: z.string().optional(), group: z.string().optional(), groupIcon: z.union([z.string(), eModelEndpointSchema]).optional(),