mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🥛 fix: Drop Stale Saved Model Defaults in Builder Forms (#15179)
This commit is contained in:
parent
4b113697b5
commit
3d2da403ce
12 changed files with 533 additions and 79 deletions
|
|
@ -227,6 +227,7 @@ export type AgentModelPanelProps = {
|
|||
agent_id?: string;
|
||||
providers: Option[];
|
||||
models: Record<string, string[] | undefined>;
|
||||
modelsReady: boolean;
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ import type { AgentForm } from '~/common';
|
|||
|
||||
// Mock toast context - define this after all mocks
|
||||
let mockShowToast: jest.Mock;
|
||||
let mockModelsQuery: {
|
||||
data: Record<string, string[]>;
|
||||
isFetchedAfterMount: boolean;
|
||||
isSuccess: boolean;
|
||||
isFetching?: boolean;
|
||||
} = { data: {}, isFetchedAfterMount: true, isSuccess: true };
|
||||
let mockAgentPanelContext = {
|
||||
activePanel: 'builder',
|
||||
agentsConfig: { allowedProviders: [] as string[] },
|
||||
setActivePanel: jest.fn(),
|
||||
endpointsConfig: {},
|
||||
setCurrentAgentId: jest.fn(),
|
||||
agent_id: 'agent-123' as string | undefined,
|
||||
};
|
||||
|
||||
// Mock notification severity enum before other imports
|
||||
jest.mock('~/common/types', () => ({
|
||||
|
|
@ -77,7 +91,7 @@ jest.mock('@librechat/client', () => ({
|
|||
|
||||
// Mock other dependencies
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useGetModelsQuery: () => ({ data: {} }),
|
||||
useGetModelsQuery: () => mockModelsQuery,
|
||||
useGetEffectivePermissionsQuery: () => ({
|
||||
data: { permissionBits: 0xffffffff }, // All permissions
|
||||
isLoading: false,
|
||||
|
|
@ -87,6 +101,8 @@ jest.mock('librechat-data-provider/react-query', () => ({
|
|||
|
||||
jest.mock('~/utils', () => ({
|
||||
createProviderOption: jest.fn((provider: string) => ({ value: provider, label: provider })),
|
||||
getAvailableAgentSelection: jest.requireActual('~/utils/agentModelSelection')
|
||||
.getAvailableAgentSelection,
|
||||
getDefaultAgentFormValues: jest.fn(() => ({
|
||||
id: '',
|
||||
name: '',
|
||||
|
|
@ -110,14 +126,7 @@ jest.mock('~/hooks/useResourcePermissions', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/Providers/AgentPanelContext', () => ({
|
||||
useAgentPanelContext: () => ({
|
||||
activePanel: 'builder',
|
||||
agentsConfig: { allowedProviders: [] },
|
||||
setActivePanel: jest.fn(),
|
||||
endpointsConfig: {},
|
||||
setCurrentAgentId: jest.fn(),
|
||||
agent_id: 'agent-123',
|
||||
}),
|
||||
useAgentPanelContext: () => mockAgentPanelContext,
|
||||
}));
|
||||
|
||||
jest.mock('~/common', () => ({
|
||||
|
|
@ -202,7 +211,6 @@ jest.mock('react-hook-form', () => {
|
|||
};
|
||||
},
|
||||
FormProvider: ({ children }: any) => children,
|
||||
useWatch: () => 'agent-123',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -300,9 +308,111 @@ describe('AgentPanel - Update Agent Toast Messages', () => {
|
|||
mockShowToast = jest.fn();
|
||||
mockFormSubmitHandler = null;
|
||||
capturedFormMethods = null;
|
||||
mockModelsQuery = {
|
||||
data: { openai: ['gpt-4'] },
|
||||
isFetchedAfterMount: true,
|
||||
isSuccess: true,
|
||||
};
|
||||
mockAgentPanelContext = {
|
||||
activePanel: 'builder',
|
||||
agentsConfig: { allowedProviders: [] },
|
||||
setActivePanel: jest.fn(),
|
||||
endpointsConfig: { openai: {} },
|
||||
setCurrentAgentId: jest.fn(),
|
||||
agent_id: 'agent-123',
|
||||
};
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('AgentPanel', () => {
|
||||
it('restores saved defaults from the current model catalogue', async () => {
|
||||
const { mockUseGetAgentByIdQuery } = setupMocks();
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {});
|
||||
mockAgentPanelContext = {
|
||||
...mockAgentPanelContext,
|
||||
endpointsConfig: { custom: {} },
|
||||
agent_id: undefined,
|
||||
};
|
||||
mockModelsQuery = {
|
||||
data: { custom: ['cached-model'] },
|
||||
isFetchedAfterMount: true,
|
||||
isSuccess: true,
|
||||
isFetching: true,
|
||||
};
|
||||
localStorage.setItem('lastAgentProvider', 'custom');
|
||||
localStorage.setItem('lastAgentModel', 'current-model');
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
const { rerender } = render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
mockModelsQuery = {
|
||||
data: { custom: ['current-model'] },
|
||||
isFetchedAfterMount: true,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
};
|
||||
rerender(<AgentPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedFormMethods?.getValues('provider')).toEqual({
|
||||
value: 'custom',
|
||||
label: 'custom',
|
||||
});
|
||||
expect(capturedFormMethods?.getValues('model')).toBe('current-model');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears unavailable saved defaults', async () => {
|
||||
const { mockUseGetAgentByIdQuery } = setupMocks();
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {});
|
||||
mockAgentPanelContext = {
|
||||
...mockAgentPanelContext,
|
||||
endpointsConfig: { custom: {} },
|
||||
agent_id: undefined,
|
||||
};
|
||||
mockModelsQuery = {
|
||||
data: { custom: ['current-model'] },
|
||||
isFetchedAfterMount: true,
|
||||
isSuccess: true,
|
||||
};
|
||||
localStorage.setItem('lastAgentProvider', 'custom');
|
||||
localStorage.setItem('lastAgentModel', 'removed-model');
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedFormMethods?.getValues('provider')).toEqual({
|
||||
value: 'custom',
|
||||
label: 'custom',
|
||||
});
|
||||
expect(capturedFormMethods?.getValues('model')).toBe('');
|
||||
});
|
||||
expect(localStorage.getItem('lastAgentModel')).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves an existing agent's configured model", async () => {
|
||||
const { mockUseGetAgentByIdQuery } = setupMocks();
|
||||
mockAgentQuery(mockUseGetAgentByIdQuery, {});
|
||||
mockAgentPanelContext = {
|
||||
...mockAgentPanelContext,
|
||||
endpointsConfig: { bedrock: {} },
|
||||
};
|
||||
mockModelsQuery = {
|
||||
data: { bedrock: ['current-model'] },
|
||||
isFetchedAfterMount: true,
|
||||
isSuccess: true,
|
||||
};
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(<AgentPanel />, { wrapper: Wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedFormMethods?.getValues('provider')).toBe('openai');
|
||||
expect(capturedFormMethods?.getValues('model')).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show "no changes" toast when version does not change', async () => {
|
||||
const { mockUseGetAgentByIdQuery, mockUpdateAgent } = setupMocks();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useCallback, useRef, useState } from 'react';
|
||||
import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SystemRoles,
|
||||
ResourceType,
|
||||
EModelEndpoint,
|
||||
LocalStorageKeys,
|
||||
PermissionBits,
|
||||
removeCodeExecutionCaller,
|
||||
resolveStatefulCodeEnvironment,
|
||||
|
|
@ -26,7 +27,11 @@ import {
|
|||
useGetExpandedAgentByIdQuery,
|
||||
useUploadAgentAvatarMutation,
|
||||
} from '~/data-provider';
|
||||
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import {
|
||||
createProviderOption,
|
||||
getAvailableAgentSelection,
|
||||
getDefaultAgentFormValues,
|
||||
} from '~/utils';
|
||||
import { useResourcePermissions } from '~/hooks/useResourcePermissions';
|
||||
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
|
|
@ -315,6 +320,7 @@ export default function AgentPanel() {
|
|||
const agentQuery = canEdit && expandedAgentQuery.data ? expandedAgentQuery : basicAgentQuery;
|
||||
|
||||
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
|
||||
const modelsReady = modelsQuery.isFetchedAfterMount && !modelsQuery.isFetching;
|
||||
const methods = useForm<AgentForm>({
|
||||
defaultValues: getDefaultAgentFormValues(defaultStatefulCodeEnvironment),
|
||||
mode: 'onChange',
|
||||
|
|
@ -329,6 +335,7 @@ export default function AgentPanel() {
|
|||
formState: { dirtyFields },
|
||||
} = methods;
|
||||
const [isAvatarUploadInFlight, setIsAvatarUploadInFlight] = useState(false);
|
||||
|
||||
const uploadAvatarMutation = useUploadAgentAvatarMutation({
|
||||
onSuccess: (updatedAgent) => {
|
||||
showToast({ message: localize('com_ui_upload_agent_avatar') });
|
||||
|
|
@ -394,6 +401,56 @@ export default function AgentPanel() {
|
|||
.map((provider) => createProviderOption(provider)),
|
||||
[endpointsConfig, allowedProviders],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (endpointsConfig == null || !modelsReady || !modelsQuery.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedProvider = localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? '';
|
||||
const storedModel = localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '';
|
||||
const storedSelection = getAvailableAgentSelection({
|
||||
provider: storedProvider,
|
||||
model: storedModel,
|
||||
providers,
|
||||
models,
|
||||
});
|
||||
|
||||
if (storedSelection.provider !== storedProvider) {
|
||||
localStorage.removeItem(LocalStorageKeys.LAST_AGENT_PROVIDER);
|
||||
localStorage.removeItem(LocalStorageKeys.LAST_AGENT_MODEL);
|
||||
} else if (storedSelection.model !== storedModel) {
|
||||
localStorage.removeItem(LocalStorageKeys.LAST_AGENT_MODEL);
|
||||
}
|
||||
|
||||
if (current_agent_id || dirtyFields.provider === true || dirtyFields.model === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProviderOption = getValues('provider');
|
||||
const selectedProvider =
|
||||
(typeof selectedProviderOption === 'string'
|
||||
? selectedProviderOption
|
||||
: (selectedProviderOption as StringOption | undefined)?.value) ?? '';
|
||||
const selectedModel = getValues('model') ?? '';
|
||||
|
||||
if (storedSelection.provider !== selectedProvider) {
|
||||
setValue('provider', createProviderOption(storedSelection.provider));
|
||||
}
|
||||
if (storedSelection.model !== selectedModel) {
|
||||
setValue('model', storedSelection.model);
|
||||
}
|
||||
}, [
|
||||
current_agent_id,
|
||||
dirtyFields.model,
|
||||
dirtyFields.provider,
|
||||
endpointsConfig,
|
||||
getValues,
|
||||
models,
|
||||
modelsQuery.isSuccess,
|
||||
modelsReady,
|
||||
providers,
|
||||
setValue,
|
||||
]);
|
||||
|
||||
/* Mutations */
|
||||
const update = useUpdateAgentMutation({
|
||||
|
|
@ -633,7 +690,12 @@ export default function AgentPanel() {
|
|||
</div>
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.model && (
|
||||
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
|
||||
<ModelPanel
|
||||
models={models}
|
||||
providers={providers}
|
||||
modelsReady={modelsReady}
|
||||
setActivePanel={setActivePanel}
|
||||
/>
|
||||
)}
|
||||
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
|
||||
<AgentConfig />
|
||||
|
|
|
|||
167
client/src/components/SidePanel/Agents/ModelPanel.test.tsx
Normal file
167
client/src/components/SidePanel/Agents/ModelPanel.test.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import type { AgentForm } from '~/common';
|
||||
import ModelPanel from './ModelPanel';
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Button: ({ children, onClick, type }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type={type} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ControlCombobox: ({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
items,
|
||||
selectedValue,
|
||||
setValue,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
items: Array<{ label: string; value: string }>;
|
||||
selectedValue: string;
|
||||
setValue: (value: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid={`${ariaLabel}-selected`}>{selectedValue}</span>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid={`${ariaLabel}-${item.value}`}
|
||||
onClick={() => setValue(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/SidePanel/Parameters/components', () => ({
|
||||
componentMapping: {},
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetEndpointsQuery: () => ({ data: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useLiveAnnouncer: () => ({ announcePolite: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | undefined>) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
function TestForm({
|
||||
defaultModel = '',
|
||||
defaultProvider = '',
|
||||
models,
|
||||
modelsReady,
|
||||
providers = [{ label: 'Custom', value: 'custom' }],
|
||||
}: {
|
||||
defaultModel?: string;
|
||||
defaultProvider?: string;
|
||||
models: Record<string, string[]>;
|
||||
modelsReady: boolean;
|
||||
providers?: Array<{ label: string; value: string }>;
|
||||
}) {
|
||||
const methods = useForm<AgentForm>({
|
||||
defaultValues: {
|
||||
provider: defaultProvider,
|
||||
model: defaultModel,
|
||||
model_parameters: {},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<ModelPanel
|
||||
providers={providers}
|
||||
models={models}
|
||||
modelsReady={modelsReady}
|
||||
setActivePanel={jest.fn()}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ModelPanel', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('disables model selection until the model catalogue is ready', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestForm
|
||||
defaultProvider="custom"
|
||||
models={{ custom: ['custom-model'] }}
|
||||
modelsReady={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId('com_ui_provider-custom')).toBeDisabled();
|
||||
expect(getByTestId('com_ui_model-custom-model')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('selects and saves the first model when the provider changes', () => {
|
||||
const providers = [
|
||||
{ label: 'Original', value: 'original' },
|
||||
{ label: 'Alternate', value: 'alternate' },
|
||||
];
|
||||
const { getByTestId } = render(
|
||||
<TestForm
|
||||
defaultProvider="original"
|
||||
defaultModel="original-model"
|
||||
models={{ original: ['original-model'], alternate: ['alternate-model'] }}
|
||||
modelsReady={true}
|
||||
providers={providers}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('com_ui_provider-alternate'));
|
||||
|
||||
expect(getByTestId('com_ui_model-selected')).toHaveTextContent('alternate-model');
|
||||
expect(localStorage.getItem('lastAgentProvider')).toBe('alternate');
|
||||
expect(localStorage.getItem('lastAgentModel')).toBe('alternate-model');
|
||||
});
|
||||
|
||||
it('preserves the model when the current provider is selected again', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestForm
|
||||
defaultProvider="custom"
|
||||
defaultModel="second-model"
|
||||
models={{ custom: ['first-model', 'second-model'] }}
|
||||
modelsReady={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('com_ui_provider-custom'));
|
||||
|
||||
expect(getByTestId('com_ui_model-selected')).toHaveTextContent('second-model');
|
||||
});
|
||||
|
||||
it('saves an explicitly selected model', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestForm
|
||||
defaultProvider="custom"
|
||||
defaultModel="first-model"
|
||||
models={{ custom: ['first-model', 'second-model'] }}
|
||||
modelsReady={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId('com_ui_model-second-model'));
|
||||
|
||||
expect(localStorage.getItem('lastAgentProvider')).toBe('custom');
|
||||
expect(localStorage.getItem('lastAgentModel')).toBe('second-model');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useEffect } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import keyBy from 'lodash/keyBy';
|
||||
import { ChevronLeft, RotateCcw } from 'lucide-react';
|
||||
import { Button, ControlCombobox } from '@librechat/client';
|
||||
|
|
@ -25,7 +25,8 @@ export default function ModelPanel({
|
|||
providers,
|
||||
setActivePanel,
|
||||
models: modelsData,
|
||||
}: Pick<AgentModelPanelProps, 'models' | 'providers' | 'setActivePanel'>) {
|
||||
modelsReady,
|
||||
}: Pick<AgentModelPanelProps, 'models' | 'modelsReady' | 'providers' | 'setActivePanel'>) {
|
||||
const localize = useLocalize();
|
||||
const { announcePolite } = useLiveAnnouncer();
|
||||
|
||||
|
|
@ -47,23 +48,6 @@ export default function ModelPanel({
|
|||
[modelsData, provider],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const _model = model ?? '';
|
||||
if (provider && _model) {
|
||||
const modelExists = models.includes(_model);
|
||||
if (!modelExists) {
|
||||
const newModels = modelsData[provider] ?? [];
|
||||
setValue('model', newModels[0] ?? '');
|
||||
}
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_MODEL, _model);
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_PROVIDER, provider);
|
||||
}
|
||||
|
||||
if (provider && !_model) {
|
||||
setValue('model', models[0] ?? '');
|
||||
}
|
||||
}, [provider, models, modelsData, setValue, model]);
|
||||
|
||||
const { data: endpointsConfig = {} } = useGetEndpointsQuery();
|
||||
|
||||
const bedrockRegions = useMemo(() => {
|
||||
|
|
@ -150,13 +134,27 @@ export default function ModelPanel({
|
|||
displayValue={alternateName[display] ?? display}
|
||||
selectPlaceholder={localize('com_ui_select_provider')}
|
||||
searchPlaceholder={localize('com_ui_select_search_provider')}
|
||||
setValue={field.onChange}
|
||||
setValue={(value) => {
|
||||
if (value === provider) {
|
||||
return;
|
||||
}
|
||||
const nextModel = modelsData[value]?.[0] ?? '';
|
||||
field.onChange(value);
|
||||
setValue('model', nextModel);
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_PROVIDER, value);
|
||||
if (nextModel) {
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_MODEL, nextModel);
|
||||
} else {
|
||||
localStorage.removeItem(LocalStorageKeys.LAST_AGENT_MODEL);
|
||||
}
|
||||
}}
|
||||
items={providers.map((provider) => ({
|
||||
label: typeof provider === 'string' ? provider : provider.label,
|
||||
value: typeof provider === 'string' ? provider : provider.value,
|
||||
}))}
|
||||
className={cn(error ? 'border-2 border-red-500' : '')}
|
||||
ariaLabel={localize('com_ui_provider')}
|
||||
disabled={!modelsReady}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
|
|
@ -197,12 +195,20 @@ export default function ModelPanel({
|
|||
: localize('com_ui_select_provider_first')
|
||||
}
|
||||
searchPlaceholder={localize('com_ui_select_model')}
|
||||
setValue={field.onChange}
|
||||
setValue={(value) => {
|
||||
field.onChange(value);
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_PROVIDER, provider);
|
||||
if (value) {
|
||||
localStorage.setItem(LocalStorageKeys.LAST_AGENT_MODEL, value);
|
||||
} else {
|
||||
localStorage.removeItem(LocalStorageKeys.LAST_AGENT_MODEL);
|
||||
}
|
||||
}}
|
||||
items={models.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}))}
|
||||
disabled={!provider}
|
||||
disabled={!provider || !modelsReady}
|
||||
className={cn('disabled:opacity-50', error ? 'border-2 border-red-500' : '')}
|
||||
ariaLabel={localize('com_ui_model')}
|
||||
isCollapsed={false}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { Spinner, useToastContext, SelectDropDown } from '@librechat/client';
|
||||
import { useForm, FormProvider, Controller, useWatch } from 'react-hook-form';
|
||||
|
|
@ -7,19 +7,26 @@ import {
|
|||
Capabilities,
|
||||
isActionTool,
|
||||
ImageVisionTool,
|
||||
LocalStorageKeys,
|
||||
defaultAssistantFormValues,
|
||||
} from 'librechat-data-provider';
|
||||
import type { FunctionTool, TConfig } from 'librechat-data-provider';
|
||||
import type { AssistantForm, AssistantPanelProps } from '~/common';
|
||||
import type { AssistantForm, AssistantPanelProps, LastSelectedModels } from '~/common';
|
||||
import {
|
||||
useCreateAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
useAvailableAgentToolsQuery,
|
||||
} from '~/data-provider';
|
||||
import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils';
|
||||
import {
|
||||
cn,
|
||||
cardStyle,
|
||||
defaultTextProps,
|
||||
getAvailableModelSelection,
|
||||
removeFocusOutlines,
|
||||
} from '~/utils';
|
||||
import AssistantConversationStarters from './AssistantConversationStarters';
|
||||
import AssistantToolsDialog from '~/components/Tools/AssistantToolsDialog';
|
||||
import { useSelectAssistant, useLocalize } from '~/hooks';
|
||||
import { useSelectAssistant, useLocalize, useLocalStorage } from '~/hooks';
|
||||
import { useAssistantsMapContext } from '~/Providers';
|
||||
import AppendDateCheckbox from './AppendDateCheckbox';
|
||||
import CapabilitiesForm from './CapabilitiesForm';
|
||||
|
|
@ -50,7 +57,13 @@ export default function AssistantPanel({
|
|||
assistantsConfig,
|
||||
version,
|
||||
}: AssistantPanelProps & { assistantsConfig?: TConfig | null }) {
|
||||
const modelsQuery = useGetModelsQuery();
|
||||
const modelsQuery = useGetModelsQuery({ refetchOnMount: 'always' });
|
||||
const models = useMemo(() => modelsQuery.data?.[endpoint] ?? [], [endpoint, modelsQuery.data]);
|
||||
const modelsReady = modelsQuery.isFetchedAfterMount && !modelsQuery.isFetching;
|
||||
const [lastSelectedModels] = useLocalStorage<LastSelectedModels | undefined>(
|
||||
LocalStorageKeys.LAST_MODEL,
|
||||
{} as LastSelectedModels,
|
||||
);
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const { data: allTools = [] } = useAvailableAgentToolsQuery();
|
||||
|
|
@ -64,10 +77,41 @@ export default function AssistantPanel({
|
|||
|
||||
const [showToolDialog, setShowToolDialog] = useState(false);
|
||||
|
||||
const { control, handleSubmit, reset, setValue, getValues } = methods;
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { dirtyFields },
|
||||
} = methods;
|
||||
const assistant = useWatch({ control, name: 'assistant' });
|
||||
const functions = useWatch({ control, name: 'functions' });
|
||||
const assistant_id = useWatch({ control, name: 'id' });
|
||||
const model = useWatch({ control, name: 'model' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelsReady || !modelsQuery.isSuccess || current_assistant_id || assistant_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = dirtyFields.model === true ? model : (lastSelectedModels?.[endpoint] ?? '');
|
||||
const nextModel = getAvailableModelSelection(candidate, models);
|
||||
if (nextModel !== model) {
|
||||
setValue('model', nextModel, { shouldDirty: false });
|
||||
}
|
||||
}, [
|
||||
assistant_id,
|
||||
current_assistant_id,
|
||||
dirtyFields.model,
|
||||
endpoint,
|
||||
lastSelectedModels,
|
||||
model,
|
||||
models,
|
||||
modelsQuery.isSuccess,
|
||||
modelsReady,
|
||||
setValue,
|
||||
]);
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
return assistantMap?.[endpoint]?.[assistant_id]?.model;
|
||||
|
|
@ -363,7 +407,8 @@ export default function AssistantPanel({
|
|||
emptyTitle={true}
|
||||
value={field.value}
|
||||
setValue={field.onChange}
|
||||
availableValues={modelsQuery.data?.[endpoint] ?? []}
|
||||
availableValues={models}
|
||||
disabled={!modelsReady}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
FileSources,
|
||||
Capabilities,
|
||||
EModelEndpoint,
|
||||
LocalStorageKeys,
|
||||
isImageVisionTool,
|
||||
defaultAssistantFormValues,
|
||||
} from 'librechat-data-provider';
|
||||
|
|
@ -19,17 +18,11 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type { UseFormReset } from 'react-hook-form';
|
||||
import type {
|
||||
Actions,
|
||||
ExtendedFile,
|
||||
AssistantForm,
|
||||
TAssistantOption,
|
||||
LastSelectedModels,
|
||||
} from '~/common';
|
||||
import type { Actions, ExtendedFile, AssistantForm, TAssistantOption } from '~/common';
|
||||
import { useListAssistantsQuery } from '~/data-provider';
|
||||
import { useLocalize, useLocalStorage } from '~/hooks';
|
||||
import { cn, createDropdownSetter } from '~/utils';
|
||||
import { useFileMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const keys = new Set([
|
||||
'name',
|
||||
|
|
@ -63,10 +56,6 @@ export default function AssistantSelect({
|
|||
const localize = useLocalize();
|
||||
const fileMap = useFileMapContext();
|
||||
const lastSelectedAssistant = useRef<string | null>(null);
|
||||
const [lastSelectedModels] = useLocalStorage<LastSelectedModels | undefined>(
|
||||
LocalStorageKeys.LAST_MODEL,
|
||||
{} as LastSelectedModels,
|
||||
);
|
||||
|
||||
const toolkits = useMemo(
|
||||
() => new Set(allTools?.filter((tool) => tool.toolkit === true).map((tool) => tool.pluginKey)),
|
||||
|
|
@ -152,10 +141,7 @@ export default function AssistantSelect({
|
|||
createMutation.reset();
|
||||
if (!assistant) {
|
||||
setCurrentAssistantId(undefined);
|
||||
return reset({
|
||||
...defaultAssistantFormValues,
|
||||
model: lastSelectedModels?.[endpoint] ?? '',
|
||||
});
|
||||
return reset(defaultAssistantFormValues);
|
||||
}
|
||||
|
||||
const update = {
|
||||
|
|
@ -231,15 +217,7 @@ export default function AssistantSelect({
|
|||
reset(formValues);
|
||||
setCurrentAssistantId(assistant.id);
|
||||
},
|
||||
[
|
||||
query.data,
|
||||
reset,
|
||||
setCurrentAssistantId,
|
||||
createMutation,
|
||||
endpoint,
|
||||
lastSelectedModels,
|
||||
toolkits,
|
||||
],
|
||||
[query.data, reset, setCurrentAssistantId, createMutation, toolkits],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
62
client/src/utils/agentModelSelection.spec.ts
Normal file
62
client/src/utils/agentModelSelection.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { getAvailableAgentSelection, getAvailableModelSelection } from './agentModelSelection';
|
||||
|
||||
describe('getAvailableModelSelection', () => {
|
||||
it('returns an empty value when a saved model is unavailable', () => {
|
||||
expect(getAvailableModelSelection('gpt-removed', ['gpt-4.1'])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableAgentSelection', () => {
|
||||
const providers = [
|
||||
{ label: 'Anthropic', value: 'anthropic' },
|
||||
{ label: 'Bedrock', value: 'bedrock' },
|
||||
];
|
||||
const models = {
|
||||
anthropic: ['claude-sonnet-4'],
|
||||
bedrock: ['claude-sonnet-4', 'claude-haiku-3'],
|
||||
};
|
||||
|
||||
it('keeps an available provider and model', () => {
|
||||
expect(
|
||||
getAvailableAgentSelection({
|
||||
provider: 'bedrock',
|
||||
model: 'claude-sonnet-4',
|
||||
providers,
|
||||
models,
|
||||
}),
|
||||
).toEqual({ provider: 'bedrock', model: 'claude-sonnet-4' });
|
||||
});
|
||||
|
||||
it('returns an empty selection when the provider is unavailable', () => {
|
||||
expect(
|
||||
getAvailableAgentSelection({
|
||||
provider: 'openAI',
|
||||
model: 'gpt-5',
|
||||
providers,
|
||||
models,
|
||||
}),
|
||||
).toEqual({ provider: '', model: '' });
|
||||
});
|
||||
|
||||
it('returns an empty selection when the provider has no model catalogue', () => {
|
||||
expect(
|
||||
getAvailableAgentSelection({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4',
|
||||
providers,
|
||||
models: { bedrock: models.bedrock },
|
||||
}),
|
||||
).toEqual({ provider: '', model: '' });
|
||||
});
|
||||
|
||||
it('keeps the provider but clears an unavailable model', () => {
|
||||
expect(
|
||||
getAvailableAgentSelection({
|
||||
provider: 'bedrock',
|
||||
model: 'claude-opus-3',
|
||||
providers,
|
||||
models,
|
||||
}),
|
||||
).toEqual({ provider: 'bedrock', model: '' });
|
||||
});
|
||||
});
|
||||
32
client/src/utils/agentModelSelection.ts
Normal file
32
client/src/utils/agentModelSelection.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
type ProviderOption = string | { value?: string | number | null };
|
||||
|
||||
export function getAvailableModelSelection(model: string, models: readonly string[]): string {
|
||||
return models.includes(model) ? model : '';
|
||||
}
|
||||
|
||||
export function getAvailableAgentSelection({
|
||||
provider,
|
||||
model,
|
||||
providers,
|
||||
models,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
providers: readonly ProviderOption[];
|
||||
models: Record<string, string[] | undefined>;
|
||||
}): { provider: string; model: string } {
|
||||
const providerExists =
|
||||
models[provider] != null &&
|
||||
providers.some((option) =>
|
||||
typeof option === 'string' ? option === provider : option.value === provider,
|
||||
);
|
||||
|
||||
if (!providerExists) {
|
||||
return { provider: '', model: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: getAvailableModelSelection(model, models[provider] ?? []),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
import { getDefaultAgentFormValues } from './forms';
|
||||
|
||||
describe('getDefaultAgentFormValues', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('uses the scalable user workspace by default', () => {
|
||||
expect(getDefaultAgentFormValues().stateful_code_environment).toBe('user');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
alternateName,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
LocalStorageKeys,
|
||||
defaultAgentFormValues,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Agent, TFile, StatefulCodeEnvironment } from 'librechat-data-provider';
|
||||
|
|
@ -44,17 +43,12 @@ export const createProviderOption = (provider: string) => ({
|
|||
value: provider,
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets default agent form values with localStorage values for model and provider.
|
||||
* This is used to initialize agent forms with the last used model and provider.
|
||||
**/
|
||||
/** Gets default agent form values. */
|
||||
export const getDefaultAgentFormValues = (
|
||||
statefulCodeEnvironment: StatefulCodeEnvironment = 'user',
|
||||
) => ({
|
||||
...defaultAgentFormValues,
|
||||
stateful_code_environment: statefulCodeEnvironment,
|
||||
model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '',
|
||||
provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''),
|
||||
avatar_file: null,
|
||||
avatar_preview: '',
|
||||
avatar_action: null,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export * from './favoritesError';
|
|||
export * from './approval';
|
||||
export * from './steer';
|
||||
export * from './activityLabels';
|
||||
export * from './agentModelSelection';
|
||||
export * from './runStepDuration';
|
||||
export * from './toolCallPhase';
|
||||
export * from './documentTitle';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue