mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🌱 feat: Support Soft Default Model Spec (#13554)
* feat: add soft default model spec * chore: sort ChatRoute imports
This commit is contained in:
parent
5c00bbe28c
commit
83bdd3d65d
7 changed files with 226 additions and 26 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
90
client/src/utils/__tests__/getDefaultModelSpec.test.ts
Normal file
90
client/src/utils/__tests__/getDefaultModelSpec.test.ts
Normal file
|
|
@ -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> = {}): 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<StoredModelSelection> | undefined {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as Partial<StoredModelSelection>;
|
||||
} 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<string, string | null | undefined>;
|
||||
return Object.values(storedModels).some(hasSelectionValue);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasModelSelection(selection?: Partial<StoredModelSelection> | 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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue