mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-05 15:27:20 +02:00
🧹 chore: Clean Up Config Fields (#12537)
* chore: remove unused `interface.endpointsMenu` config field * chore: address review — restore JSDoc UI-only example, add Zod strip test * chore: remove unused `interface.sidePanel` config field * chore: restrict fileStrategy/fileStrategies schema to valid storage backends * fix: use valid FileStorage value in AppService test * chore: address review — version bump, exhaustiveness guard, JSDoc, configSchema test * chore: remove debug logger.log from MessageIcon render path * fix: rewrite MessageIcon render tests to use render counting instead of logger spying * chore: bump librechat-data-provider to 0.8.407 * chore: sync example YAML version to 1.3.7
This commit is contained in:
parent
b4d97bd888
commit
ea28dbfa89
17 changed files with 211 additions and 190 deletions
|
|
@ -1,10 +1,10 @@
|
|||
import { useMemo, useEffect, useRef, memo } from 'react';
|
||||
import { useMemo, memo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import type { Assistant, Agent } from 'librechat-data-provider';
|
||||
import type { TMessageIcon } from '~/common';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getIconEndpoint, logger } from '~/utils';
|
||||
import { getIconEndpoint } from '~/utils';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
|
||||
type MessageIconProps = {
|
||||
|
|
@ -19,25 +19,20 @@ type MessageIconProps = {
|
|||
* this component renders display properties only, not identity-derived content.
|
||||
*/
|
||||
export function arePropsEqual(prev: MessageIconProps, next: MessageIconProps): boolean {
|
||||
const checks: [string, unknown, unknown][] = [
|
||||
['iconData.endpoint', prev.iconData?.endpoint, next.iconData?.endpoint],
|
||||
['iconData.model', prev.iconData?.model, next.iconData?.model],
|
||||
['iconData.iconURL', prev.iconData?.iconURL, next.iconData?.iconURL],
|
||||
['iconData.modelLabel', prev.iconData?.modelLabel, next.iconData?.modelLabel],
|
||||
['iconData.isCreatedByUser', prev.iconData?.isCreatedByUser, next.iconData?.isCreatedByUser],
|
||||
['agent.name', prev.agent?.name, next.agent?.name],
|
||||
['agent.avatar.filepath', prev.agent?.avatar?.filepath, next.agent?.avatar?.filepath],
|
||||
['assistant.name', prev.assistant?.name, next.assistant?.name],
|
||||
[
|
||||
'assistant.metadata.avatar',
|
||||
prev.assistant?.metadata?.avatar,
|
||||
next.assistant?.metadata?.avatar,
|
||||
],
|
||||
const checks: [unknown, unknown][] = [
|
||||
[prev.iconData?.endpoint, next.iconData?.endpoint],
|
||||
[prev.iconData?.model, next.iconData?.model],
|
||||
[prev.iconData?.iconURL, next.iconData?.iconURL],
|
||||
[prev.iconData?.modelLabel, next.iconData?.modelLabel],
|
||||
[prev.iconData?.isCreatedByUser, next.iconData?.isCreatedByUser],
|
||||
[prev.agent?.name, next.agent?.name],
|
||||
[prev.agent?.avatar?.filepath, next.agent?.avatar?.filepath],
|
||||
[prev.assistant?.name, next.assistant?.name],
|
||||
[prev.assistant?.metadata?.avatar, next.assistant?.metadata?.avatar],
|
||||
];
|
||||
|
||||
for (const [field, prevVal, nextVal] of checks) {
|
||||
for (const [prevVal, nextVal] of checks) {
|
||||
if (prevVal !== nextVal) {
|
||||
logger.log('icon_memo_diff', `field "${field}" changed:`, prevVal, '→', nextVal);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -45,28 +40,6 @@ export function arePropsEqual(prev: MessageIconProps, next: MessageIconProps): b
|
|||
}
|
||||
|
||||
const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => {
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current += 1;
|
||||
|
||||
useEffect(() => {
|
||||
logger.log(
|
||||
'icon_lifecycle',
|
||||
'MOUNT',
|
||||
iconData?.modelLabel,
|
||||
`render #${renderCountRef.current}`,
|
||||
);
|
||||
return () => {
|
||||
logger.log('icon_lifecycle', 'UNMOUNT', iconData?.modelLabel);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
logger.log(
|
||||
'icon_data',
|
||||
`render #${renderCountRef.current}`,
|
||||
iconData?.isCreatedByUser ? 'user' : iconData?.modelLabel,
|
||||
iconData,
|
||||
);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const agentName = agent?.name ?? '';
|
||||
|
|
|
|||
|
|
@ -11,29 +11,25 @@ jest.mock('librechat-data-provider', () => ({
|
|||
jest.mock('~/data-provider', () => ({
|
||||
useGetEndpointsQuery: jest.fn(() => ({ data: {} })),
|
||||
}));
|
||||
|
||||
// logger is a plain object with a real function — not a jest.fn() —
|
||||
// so restoreMocks/clearMocks won't touch it. We spy on it per-test instead.
|
||||
const logCalls: unknown[][] = [];
|
||||
jest.mock('~/utils', () => ({
|
||||
getIconEndpoint: jest.fn(() => 'agents'),
|
||||
logger: {
|
||||
log: (...args: unknown[]) => {
|
||||
logCalls.push(args);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const iconRenderCount = { current: 0 };
|
||||
|
||||
jest.mock('~/components/Endpoints/ConvoIconURL', () => {
|
||||
const ConvoIconURL = (props: Record<string, unknown>) => (
|
||||
<div data-testid="convo-icon-url" data-icon-url={props.iconURL as string} />
|
||||
);
|
||||
const ConvoIconURL = (props: Record<string, unknown>) => {
|
||||
iconRenderCount.current += 1;
|
||||
return <div data-testid="convo-icon-url" data-icon-url={props.iconURL as string} />;
|
||||
};
|
||||
ConvoIconURL.displayName = 'ConvoIconURL';
|
||||
return { __esModule: true, default: ConvoIconURL };
|
||||
});
|
||||
jest.mock('~/components/Endpoints/Icon', () => {
|
||||
const Icon = (props: Record<string, unknown>) => (
|
||||
<div data-testid="icon" data-icon-url={props.iconURL as string} />
|
||||
);
|
||||
const Icon = (props: Record<string, unknown>) => {
|
||||
iconRenderCount.current += 1;
|
||||
return <div data-testid="icon" data-icon-url={props.iconURL as string} />;
|
||||
};
|
||||
Icon.displayName = 'Icon';
|
||||
return { __esModule: true, default: Icon };
|
||||
});
|
||||
|
|
@ -58,79 +54,68 @@ const baseIconData: TMessageIcon = {
|
|||
|
||||
describe('MessageIcon render cycles', () => {
|
||||
beforeEach(() => {
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
});
|
||||
|
||||
it('renders once on initial mount', () => {
|
||||
render(<MessageIcon iconData={baseIconData} agent={makeAgent()} />);
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(1);
|
||||
expect(iconRenderCount.current).toBe(1);
|
||||
});
|
||||
|
||||
it('does not re-render when parent re-renders with same field values but new object references', () => {
|
||||
const agent = makeAgent();
|
||||
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent} />);
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
|
||||
// Simulate parent re-render: new iconData object (same field values), new agent object (same data)
|
||||
rerender(<MessageIcon iconData={{ ...baseIconData }} agent={makeAgent()} />);
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(0);
|
||||
expect(iconRenderCount.current).toBe(0);
|
||||
});
|
||||
|
||||
it('does not re-render when agent object reference changes but name and avatar are the same', () => {
|
||||
const agent1 = makeAgent();
|
||||
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent1} />);
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
|
||||
// New agent object with different id but same display fields
|
||||
const agent2 = makeAgent({ id: 'agent_456' });
|
||||
rerender(<MessageIcon iconData={baseIconData} agent={agent2} />);
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(0);
|
||||
expect(iconRenderCount.current).toBe(0);
|
||||
});
|
||||
|
||||
it('re-renders when agent avatar filepath changes', () => {
|
||||
const agent1 = makeAgent();
|
||||
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent1} />);
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
|
||||
const agent2 = makeAgent({ avatar: { filepath: '/images/new-avatar.png' } });
|
||||
rerender(<MessageIcon iconData={baseIconData} agent={agent2} />);
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(1);
|
||||
expect(iconRenderCount.current).toBe(1);
|
||||
});
|
||||
|
||||
it('re-renders when agent goes from undefined to defined (name changes from undefined to string)', () => {
|
||||
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={undefined} />);
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
|
||||
rerender(<MessageIcon iconData={baseIconData} agent={makeAgent()} />);
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(1);
|
||||
expect(iconRenderCount.current).toBe(1);
|
||||
});
|
||||
|
||||
describe('simulates message lifecycle', () => {
|
||||
it('renders exactly twice during new message + streaming start: initial render + modelLabel update', () => {
|
||||
// Phase 1: Initial response message created by useChatFunctions
|
||||
// model is set to agent_id, iconURL is undefined, modelLabel is '' or sender
|
||||
const initialIconData: TMessageIcon = {
|
||||
endpoint: EModelEndpoint.agents,
|
||||
model: 'agent_123',
|
||||
iconURL: undefined,
|
||||
modelLabel: '', // Not yet resolved
|
||||
modelLabel: '',
|
||||
isCreatedByUser: false,
|
||||
};
|
||||
const agent = makeAgent();
|
||||
|
||||
const { rerender } = render(<MessageIcon iconData={initialIconData} agent={agent} />);
|
||||
|
||||
// Phase 2: First streaming chunk arrives, messageLabel resolves to agent name
|
||||
// This is a legitimate re-render — modelLabel changed from '' to 'GitHub Agent'
|
||||
const streamingIconData: TMessageIcon = {
|
||||
...initialIconData,
|
||||
modelLabel: 'GitHub Agent',
|
||||
|
|
@ -138,9 +123,7 @@ describe('MessageIcon render cycles', () => {
|
|||
|
||||
rerender(<MessageIcon iconData={streamingIconData} agent={agent} />);
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
// Exactly 2: initial mount + modelLabel change
|
||||
expect(iconDataCalls).toHaveLength(2);
|
||||
expect(iconRenderCount.current).toBe(2);
|
||||
});
|
||||
|
||||
it('does NOT re-render on subsequent streaming chunks (content changes, isSubmitting stays true)', () => {
|
||||
|
|
@ -154,17 +137,13 @@ describe('MessageIcon render cycles', () => {
|
|||
const agent = makeAgent();
|
||||
|
||||
const { rerender } = render(<MessageIcon iconData={iconData} agent={agent} />);
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
|
||||
// Simulate multiple parent re-renders from streaming chunks
|
||||
// Parent (ContentRender) re-renders because chatContext changed,
|
||||
// but MessageIcon props are identical field-by-field
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rerender(<MessageIcon iconData={{ ...iconData }} agent={makeAgent()} />);
|
||||
}
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(0);
|
||||
expect(iconRenderCount.current).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT re-render when agentsMap context updates with same agent data', () => {
|
||||
|
|
@ -176,18 +155,15 @@ describe('MessageIcon render cycles', () => {
|
|||
isCreatedByUser: false,
|
||||
};
|
||||
|
||||
// First render with agent from original agentsMap
|
||||
const agent1 = makeAgent();
|
||||
const { rerender } = render(<MessageIcon iconData={iconData} agent={agent1} />);
|
||||
logCalls.length = 0;
|
||||
iconRenderCount.current = 0;
|
||||
|
||||
// agentsMap refetched → new agent object, same display data
|
||||
const agent2 = makeAgent();
|
||||
expect(agent1).not.toBe(agent2); // different reference
|
||||
expect(agent1).not.toBe(agent2);
|
||||
rerender(<MessageIcon iconData={iconData} agent={agent2} />);
|
||||
|
||||
const iconDataCalls = logCalls.filter((c) => c[0] === 'icon_data');
|
||||
expect(iconDataCalls).toHaveLength(0);
|
||||
expect(iconRenderCount.current).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# https://www.librechat.ai/docs/configuration/librechat_yaml
|
||||
|
||||
# Configuration version (required)
|
||||
version: 1.3.6
|
||||
version: 1.3.7
|
||||
|
||||
# Cache settings: Set to true to enable caching
|
||||
cache: true
|
||||
|
|
@ -80,10 +80,8 @@ interface:
|
|||
|
||||
By using the Website, you acknowledge that you have read these Terms of Service and agree to be bound by them.
|
||||
|
||||
endpointsMenu: true
|
||||
modelSelect: true
|
||||
parameters: true
|
||||
sidePanel: true
|
||||
presets: true
|
||||
prompts:
|
||||
use: true
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function createHandlers(overrides = {}) {
|
|||
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(true),
|
||||
|
||||
getAppConfig: jest.fn().mockResolvedValue({ interface: { endpointsMenu: true } }),
|
||||
getAppConfig: jest.fn().mockResolvedValue({ interface: { modelSelect: true } }),
|
||||
...overrides,
|
||||
};
|
||||
const handlers = createAdminConfigHandlers(deps);
|
||||
|
|
@ -133,7 +133,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: { interface: { endpointsMenu: false } } },
|
||||
body: { overrides: { interface: { modelSelect: false } } },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
|
|
@ -148,7 +148,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: { interface: { endpointsMenu: false } } },
|
||||
body: { overrides: { interface: { modelSelect: false } } },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
overrides: {
|
||||
interface: { endpointsMenu: false, prompts: false, agents: { use: false } },
|
||||
interface: { modelSelect: false, prompts: false, agents: { use: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -188,7 +188,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
|
||||
expect(savedOverrides.interface).toEqual({ endpointsMenu: false });
|
||||
expect(savedOverrides.interface).toEqual({ modelSelect: false });
|
||||
});
|
||||
|
||||
it('preserves UI sub-keys in composite permission fields like mcpServers', async () => {
|
||||
|
|
@ -263,17 +263,13 @@ describe('createAdminConfigHandlers', () => {
|
|||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.endpointsMenu' },
|
||||
query: { fieldPath: 'interface.modelSelect' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(deps.unsetConfigField).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
'interface.endpointsMenu',
|
||||
);
|
||||
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect');
|
||||
});
|
||||
|
||||
it('allows deleting mcpServers UI sub-key paths', async () => {
|
||||
|
|
@ -343,18 +339,14 @@ describe('createAdminConfigHandlers', () => {
|
|||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.endpointsMenu' },
|
||||
query: { fieldPath: 'interface.modelSelect' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.unsetConfigField).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
'interface.endpointsMenu',
|
||||
);
|
||||
expect(deps.unsetConfigField).toHaveBeenCalledWith('role', 'admin', 'interface.modelSelect');
|
||||
});
|
||||
|
||||
it('returns 400 when fieldPath query param is missing', async () => {
|
||||
|
|
@ -407,7 +399,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
entries: [
|
||||
{ fieldPath: 'interface.endpointsMenu', value: false },
|
||||
{ fieldPath: 'interface.modelSelect', value: false },
|
||||
{ fieldPath: 'interface.prompts', value: false },
|
||||
],
|
||||
},
|
||||
|
|
@ -418,7 +410,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(patchedFields['interface.endpointsMenu']).toBe(false);
|
||||
expect(patchedFields['interface.modelSelect']).toBe(false);
|
||||
expect(patchedFields['interface.prompts']).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -632,21 +624,21 @@ describe('createAdminConfigHandlers', () => {
|
|||
name: 'upsertConfigOverrides',
|
||||
reqOverrides: {
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: { interface: { endpointsMenu: false } } },
|
||||
body: { overrides: { interface: { modelSelect: false } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'patchConfigField',
|
||||
reqOverrides: {
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { entries: [{ fieldPath: 'interface.endpointsMenu', value: false }] },
|
||||
body: { entries: [{ fieldPath: 'interface.modelSelect', value: false }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deleteConfigField',
|
||||
reqOverrides: {
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.endpointsMenu' },
|
||||
query: { fieldPath: 'interface.modelSelect' },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -775,7 +767,7 @@ describe('createAdminConfigHandlers', () => {
|
|||
await handlers.getBaseConfig(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.config).toEqual({ interface: { endpointsMenu: true } });
|
||||
expect(res.body!.config).toEqual({ interface: { modelSelect: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { isValidFieldPath, getTopLevelSection } from './config';
|
|||
|
||||
describe('isValidFieldPath', () => {
|
||||
it('accepts simple dot paths', () => {
|
||||
expect(isValidFieldPath('interface.endpointsMenu')).toBe(true);
|
||||
expect(isValidFieldPath('interface.modelSelect')).toBe(true);
|
||||
expect(isValidFieldPath('registration.socialLogins')).toBe(true);
|
||||
expect(isValidFieldPath('a')).toBe(true);
|
||||
expect(isValidFieldPath('a.b.c.d')).toBe(true);
|
||||
|
|
@ -47,7 +47,7 @@ describe('isValidFieldPath', () => {
|
|||
|
||||
describe('getTopLevelSection', () => {
|
||||
it('returns first segment of a dot path', () => {
|
||||
expect(getTopLevelSection('interface.endpointsMenu')).toBe('interface');
|
||||
expect(getTopLevelSection('interface.modelSelect')).toBe('interface');
|
||||
expect(getTopLevelSection('registration.socialLogins.github')).toBe('registration');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function getTopLevelSection(fieldPath: string): string {
|
|||
* - `"interface.mcpServers.use"` → true (permission sub-key)
|
||||
* - `"interface.mcpServers.placeholder"` → false (UI-only sub-key)
|
||||
* - `"interface.peoplePicker.users"` → true (all peoplePicker sub-keys are permissions)
|
||||
* - `"interface.endpointsMenu"` → false (UI-only field)
|
||||
* - `"interface.modelSelect"` → false (UI-only field)
|
||||
*/
|
||||
function isInterfacePermissionPath(fieldPath: string): boolean {
|
||||
const parts = fieldPath.split('.');
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ describe('AppService', () => {
|
|||
it('should correctly assign process.env and initialize app config based on custom config', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
registration: { socialLogins: ['testLogin'] },
|
||||
fileStrategy: 'testStrategy' as FileSources,
|
||||
fileStrategy: FileSources.s3,
|
||||
balance: {
|
||||
enabled: true,
|
||||
},
|
||||
|
|
@ -93,22 +93,20 @@ describe('AppService', () => {
|
|||
|
||||
const result = await AppService({ config, systemTools: mockSystemTools });
|
||||
|
||||
expect(process.env.CDN_PROVIDER).toEqual('testStrategy');
|
||||
expect(process.env.CDN_PROVIDER).toEqual('s3');
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
fileStrategy: 'testStrategy',
|
||||
fileStrategy: 's3',
|
||||
}),
|
||||
registration: expect.objectContaining({
|
||||
socialLogins: ['testLogin'],
|
||||
}),
|
||||
fileStrategy: 'testStrategy',
|
||||
fileStrategy: 's3',
|
||||
interfaceConfig: expect.objectContaining({
|
||||
endpointsMenu: true,
|
||||
modelSelect: true,
|
||||
parameters: true,
|
||||
sidePanel: true,
|
||||
presets: true,
|
||||
}),
|
||||
mcpConfig: null,
|
||||
|
|
|
|||
|
|
@ -192,16 +192,13 @@ export function checkInterfaceConfig(appConfig: AppConfig) {
|
|||
if (i === 0) i++;
|
||||
}
|
||||
|
||||
// warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
|
||||
// warn about config.modelSpecs.enforce if true and if any of these, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
|
||||
if (
|
||||
appConfig?.modelSpecs?.enforce &&
|
||||
(interfaceConfig?.endpointsMenu ||
|
||||
interfaceConfig?.modelSelect ||
|
||||
interfaceConfig?.presets ||
|
||||
interfaceConfig?.parameters)
|
||||
(interfaceConfig?.modelSelect || interfaceConfig?.presets || interfaceConfig?.parameters)
|
||||
) {
|
||||
logger.warn(
|
||||
"Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.",
|
||||
"Note: Enforcing model specs can conflict with the interface options: modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.",
|
||||
);
|
||||
if (i === 0) i++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const createTestAppConfig = (overrides: Partial<AppConfig> = {}): AppConfig => {
|
|||
version: '1.0.0',
|
||||
cache: true,
|
||||
interface: {
|
||||
endpointsMenu: true,
|
||||
modelSelect: true,
|
||||
},
|
||||
registration: {
|
||||
socialLogins: [],
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function createMockCache(namespace = 'app_config') {
|
|||
|
||||
function createDeps(overrides = {}) {
|
||||
const cache = createMockCache();
|
||||
const baseConfig = { interfaceConfig: { endpointsMenu: true }, endpoints: ['openAI'] };
|
||||
const baseConfig = { interfaceConfig: { modelSelect: true }, endpoints: ['openAI'] };
|
||||
|
||||
return {
|
||||
loadBaseConfig: jest.fn().mockResolvedValue(baseConfig),
|
||||
|
|
@ -79,7 +79,7 @@ describe('createAppConfigService', () => {
|
|||
getApplicableConfigs: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ priority: 10, overrides: { interface: { endpointsMenu: false } }, isActive: true },
|
||||
{ priority: 10, overrides: { interface: { modelSelect: false } }, isActive: true },
|
||||
]),
|
||||
});
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
|
@ -125,7 +125,7 @@ describe('createAppConfigService', () => {
|
|||
getApplicableConfigs: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ priority: 10, overrides: { interface: { endpointsMenu: false } }, isActive: true },
|
||||
{ priority: 10, overrides: { interface: { modelSelect: false } }, isActive: true },
|
||||
]),
|
||||
});
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
|
@ -133,7 +133,7 @@ describe('createAppConfigService', () => {
|
|||
const config = await getAppConfig({ role: 'ADMIN' });
|
||||
|
||||
const merged = config as TestConfig;
|
||||
expect(merged.interfaceConfig?.endpointsMenu).toBe(false);
|
||||
expect(merged.interfaceConfig?.modelSelect).toBe(false);
|
||||
expect(merged.endpoints).toEqual(['openAI']);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "librechat-data-provider",
|
||||
"version": "0.8.406",
|
||||
"version": "0.8.407",
|
||||
"description": "data services for librechat apps",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import {
|
|||
azureEndpointSchema,
|
||||
endpointSchema,
|
||||
configSchema,
|
||||
interfaceSchema,
|
||||
fileStorageSchema,
|
||||
fileStrategiesSchema,
|
||||
} from '../src/config';
|
||||
import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas';
|
||||
import { FileSources } from '../src/types/files';
|
||||
|
||||
describe('paramDefinitionSchema', () => {
|
||||
it('accepts a minimal definition with only key', () => {
|
||||
|
|
@ -421,3 +425,80 @@ describe('azureEndpointSchema', () => {
|
|||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileStorageSchema', () => {
|
||||
const validStrategies = [
|
||||
FileSources.local,
|
||||
FileSources.firebase,
|
||||
FileSources.s3,
|
||||
FileSources.azure_blob,
|
||||
];
|
||||
const invalidStrategies = [
|
||||
FileSources.openai,
|
||||
FileSources.azure,
|
||||
FileSources.vectordb,
|
||||
FileSources.execute_code,
|
||||
FileSources.mistral_ocr,
|
||||
FileSources.azure_mistral_ocr,
|
||||
FileSources.vertexai_mistral_ocr,
|
||||
FileSources.text,
|
||||
FileSources.document_parser,
|
||||
];
|
||||
|
||||
for (const strategy of validStrategies) {
|
||||
it(`accepts storage strategy "${strategy}"`, () => {
|
||||
expect(fileStorageSchema.safeParse(strategy).success).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const strategy of invalidStrategies) {
|
||||
it(`rejects processing strategy "${strategy}"`, () => {
|
||||
expect(fileStorageSchema.safeParse(strategy).success).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('fileStrategiesSchema', () => {
|
||||
it('accepts valid storage strategies for all sub-fields', () => {
|
||||
const result = fileStrategiesSchema.safeParse({
|
||||
default: FileSources.s3,
|
||||
avatar: FileSources.local,
|
||||
image: FileSources.firebase,
|
||||
document: FileSources.azure_blob,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects processing strategies in sub-fields', () => {
|
||||
const result = fileStrategiesSchema.safeParse({
|
||||
default: FileSources.vectordb,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configSchema fileStrategy', () => {
|
||||
it('rejects a processing strategy as fileStrategy', () => {
|
||||
const result = configSchema.safeParse({ version: '1.3.7', fileStrategy: FileSources.vectordb });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults fileStrategy to local when absent', () => {
|
||||
const result = configSchema.safeParse({ version: '1.3.7' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.fileStrategy).toBe(FileSources.local);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interfaceSchema', () => {
|
||||
it('silently strips removed legacy fields', () => {
|
||||
const result = interfaceSchema.parse({
|
||||
endpointsMenu: true,
|
||||
sidePanel: true,
|
||||
modelSelect: false,
|
||||
});
|
||||
expect(result).not.toHaveProperty('endpointsMenu');
|
||||
expect(result).not.toHaveProperty('sidePanel');
|
||||
expect(result.modelSelect).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,14 +62,27 @@ export enum SettingsViews {
|
|||
advanced = 'advanced',
|
||||
}
|
||||
|
||||
/** Validates any FileSources value — use for file metadata, DB records, and upload routing. */
|
||||
export const fileSourceSchema = z.nativeEnum(FileSources);
|
||||
|
||||
/** Storage backend strategies only — use for config fields that set where files are stored. */
|
||||
const FILE_STORAGE_BACKENDS = [
|
||||
FileSources.local,
|
||||
FileSources.firebase,
|
||||
FileSources.s3,
|
||||
FileSources.azure_blob,
|
||||
] as const satisfies ReadonlyArray<FileSources>;
|
||||
|
||||
export const fileStorageSchema = z.enum(FILE_STORAGE_BACKENDS);
|
||||
|
||||
export type FileStorage = z.infer<typeof fileStorageSchema>;
|
||||
|
||||
export const fileStrategiesSchema = z
|
||||
.object({
|
||||
default: fileSourceSchema.optional(),
|
||||
avatar: fileSourceSchema.optional(),
|
||||
image: fileSourceSchema.optional(),
|
||||
document: fileSourceSchema.optional(),
|
||||
default: fileStorageSchema.optional(),
|
||||
avatar: fileStorageSchema.optional(),
|
||||
image: fileStorageSchema.optional(),
|
||||
document: fileStorageSchema.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
|
@ -677,10 +690,8 @@ export const interfaceSchema = z
|
|||
termsOfService: termsOfServiceSchema.optional(),
|
||||
customWelcome: z.string().optional(),
|
||||
mcpServers: mcpServersSchema.optional(),
|
||||
endpointsMenu: z.boolean().optional(),
|
||||
modelSelect: z.boolean().optional(),
|
||||
parameters: z.boolean().optional(),
|
||||
sidePanel: z.boolean().optional(),
|
||||
multiConvo: z.boolean().optional(),
|
||||
bookmarks: z.boolean().optional(),
|
||||
memories: z.boolean().optional(),
|
||||
|
|
@ -735,10 +746,8 @@ export const interfaceSchema = z
|
|||
.optional(),
|
||||
})
|
||||
.default({
|
||||
endpointsMenu: true,
|
||||
modelSelect: true,
|
||||
parameters: true,
|
||||
sidePanel: true,
|
||||
presets: true,
|
||||
multiConvo: true,
|
||||
bookmarks: true,
|
||||
|
|
@ -1059,7 +1068,7 @@ export const configSchema = z.object({
|
|||
.optional(),
|
||||
interface: interfaceSchema,
|
||||
turnstile: turnstileSchema.optional(),
|
||||
fileStrategy: fileSourceSchema.default(FileSources.local),
|
||||
fileStrategy: fileStorageSchema.default(FileSources.local),
|
||||
fileStrategies: fileStrategiesSchema,
|
||||
actions: z
|
||||
.object({
|
||||
|
|
@ -1833,7 +1842,7 @@ export enum Constants {
|
|||
/** Key for the app's version. */
|
||||
VERSION = 'v0.8.4',
|
||||
/** Key for the Custom Config's version (librechat.yaml). */
|
||||
CONFIG_VERSION = '1.3.6',
|
||||
CONFIG_VERSION = '1.3.7',
|
||||
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
|
||||
NO_PARENT = '00000000-0000-0000-0000-000000000000',
|
||||
/** Standard value to use whatever the submission prelim. `responseMessageId` is */
|
||||
|
|
|
|||
|
|
@ -29,14 +29,11 @@ export async function loadDefaultInterface({
|
|||
|
||||
const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({
|
||||
// UI elements - use schema defaults
|
||||
endpointsMenu:
|
||||
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
|
||||
modelSelect:
|
||||
interfaceConfig?.modelSelect ??
|
||||
(hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect),
|
||||
parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters),
|
||||
presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets),
|
||||
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
|
||||
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
|
||||
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
|
||||
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function fakeConfig(overrides: Record<string, unknown>, priority: number): IConf
|
|||
}
|
||||
|
||||
const baseConfig = {
|
||||
interfaceConfig: { endpointsMenu: true, sidePanel: true },
|
||||
interfaceConfig: { modelSelect: true, parameters: true },
|
||||
registration: { enabled: true },
|
||||
endpoints: ['openAI'],
|
||||
} as unknown as AppConfig;
|
||||
|
|
@ -32,11 +32,11 @@ describe('mergeConfigOverrides', () => {
|
|||
});
|
||||
|
||||
it('deep merges interface UI fields into interfaceConfig', () => {
|
||||
const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)];
|
||||
const configs = [fakeConfig({ interface: { modelSelect: false } }, 10)];
|
||||
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;
|
||||
const iface = result.interfaceConfig as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBe(false);
|
||||
expect(iface.sidePanel).toBe(true);
|
||||
expect(iface.modelSelect).toBe(false);
|
||||
expect(iface.parameters).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts by priority — higher priority wins', () => {
|
||||
|
|
@ -58,16 +58,16 @@ describe('mergeConfigOverrides', () => {
|
|||
|
||||
it('does not mutate the base config', () => {
|
||||
const original = JSON.parse(JSON.stringify(baseConfig));
|
||||
const configs = [fakeConfig({ interface: { endpointsMenu: false } }, 10)];
|
||||
const configs = [fakeConfig({ interface: { modelSelect: false } }, 10)];
|
||||
mergeConfigOverrides(baseConfig, configs);
|
||||
expect(baseConfig).toEqual(original);
|
||||
});
|
||||
|
||||
it('handles null override values', () => {
|
||||
const configs = [fakeConfig({ interface: { endpointsMenu: null } }, 10)];
|
||||
const configs = [fakeConfig({ interface: { modelSelect: null } }, 10)];
|
||||
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;
|
||||
const iface = result.interfaceConfig as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBeNull();
|
||||
expect(iface.modelSelect).toBeNull();
|
||||
});
|
||||
|
||||
it('skips configs with no overrides object', () => {
|
||||
|
|
@ -97,20 +97,20 @@ describe('mergeConfigOverrides', () => {
|
|||
|
||||
it('merges three priority levels in order', () => {
|
||||
const configs = [
|
||||
fakeConfig({ interface: { endpointsMenu: false } }, 0),
|
||||
fakeConfig({ interface: { endpointsMenu: true, sidePanel: false } }, 10),
|
||||
fakeConfig({ interface: { sidePanel: true } }, 100),
|
||||
fakeConfig({ interface: { modelSelect: false } }, 0),
|
||||
fakeConfig({ interface: { modelSelect: true, parameters: false } }, 10),
|
||||
fakeConfig({ interface: { parameters: true } }, 100),
|
||||
];
|
||||
const result = mergeConfigOverrides(baseConfig, configs) as unknown as Record<string, unknown>;
|
||||
const iface = result.interfaceConfig as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBe(true);
|
||||
expect(iface.sidePanel).toBe(true);
|
||||
expect(iface.modelSelect).toBe(true);
|
||||
expect(iface.parameters).toBe(true);
|
||||
});
|
||||
|
||||
it('remaps all renamed YAML keys (exhaustiveness check)', () => {
|
||||
const base = {
|
||||
mcpConfig: null,
|
||||
interfaceConfig: { endpointsMenu: true },
|
||||
interfaceConfig: { modelSelect: true },
|
||||
turnstileConfig: {},
|
||||
} as unknown as AppConfig;
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ describe('mergeConfigOverrides', () => {
|
|||
fakeConfig(
|
||||
{
|
||||
mcpServers: { srv: { url: 'http://mcp' } },
|
||||
interface: { endpointsMenu: false },
|
||||
interface: { modelSelect: false },
|
||||
turnstile: { siteKey: 'key-123' },
|
||||
},
|
||||
10,
|
||||
|
|
@ -127,7 +127,7 @@ describe('mergeConfigOverrides', () => {
|
|||
const result = mergeConfigOverrides(base, configs) as unknown as Record<string, unknown>;
|
||||
|
||||
expect(result.mcpConfig).toEqual({ srv: { url: 'http://mcp' } });
|
||||
expect((result.interfaceConfig as Record<string, unknown>).endpointsMenu).toBe(false);
|
||||
expect((result.interfaceConfig as Record<string, unknown>).modelSelect).toBe(false);
|
||||
expect((result.turnstileConfig as Record<string, unknown>).siteKey).toBe('key-123');
|
||||
|
||||
expect(result.mcpServers).toBeUndefined();
|
||||
|
|
@ -137,14 +137,14 @@ describe('mergeConfigOverrides', () => {
|
|||
|
||||
it('strips interface permission fields from overrides', () => {
|
||||
const base = {
|
||||
interfaceConfig: { endpointsMenu: true, sidePanel: true },
|
||||
interfaceConfig: { modelSelect: true, parameters: true },
|
||||
} as unknown as AppConfig;
|
||||
|
||||
const configs = [
|
||||
fakeConfig(
|
||||
{
|
||||
interface: {
|
||||
endpointsMenu: false,
|
||||
modelSelect: false,
|
||||
prompts: false,
|
||||
agents: { use: false },
|
||||
marketplace: { use: false },
|
||||
|
|
@ -157,14 +157,14 @@ describe('mergeConfigOverrides', () => {
|
|||
const iface = result.interfaceConfig as Record<string, unknown>;
|
||||
|
||||
// UI field should be merged
|
||||
expect(iface.endpointsMenu).toBe(false);
|
||||
expect(iface.modelSelect).toBe(false);
|
||||
// Boolean permission fields should be stripped
|
||||
expect(iface.prompts).toBeUndefined();
|
||||
// Object permission fields with only permission sub-keys should be stripped
|
||||
expect(iface.agents).toBeUndefined();
|
||||
expect(iface.marketplace).toBeUndefined();
|
||||
// Untouched base field preserved
|
||||
expect(iface.sidePanel).toBe(true);
|
||||
expect(iface.parameters).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves UI sub-keys in composite permission fields like mcpServers', () => {
|
||||
|
|
@ -220,7 +220,7 @@ describe('mergeConfigOverrides', () => {
|
|||
|
||||
it('drops interface entirely when only permission fields are present', () => {
|
||||
const base = {
|
||||
interfaceConfig: { endpointsMenu: true },
|
||||
interfaceConfig: { modelSelect: true },
|
||||
} as unknown as AppConfig;
|
||||
|
||||
const configs = [fakeConfig({ interface: { prompts: false, agents: false } }, 10)];
|
||||
|
|
@ -228,7 +228,7 @@ describe('mergeConfigOverrides', () => {
|
|||
const iface = result.interfaceConfig as Record<string, unknown>;
|
||||
|
||||
// Base should be unchanged
|
||||
expect(iface.endpointsMenu).toBe(true);
|
||||
expect(iface.modelSelect).toBe(true);
|
||||
expect(iface.prompts).toBeUndefined();
|
||||
expect(iface.agents).toBeUndefined();
|
||||
});
|
||||
|
|
@ -281,7 +281,7 @@ describe('INTERFACE_PERMISSION_FIELDS', () => {
|
|||
});
|
||||
|
||||
it('does not contain UI-only fields', () => {
|
||||
const uiFields = ['endpointsMenu', 'modelSelect', 'parameters', 'presets', 'sidePanel'];
|
||||
const uiFields = ['modelSelect', 'parameters', 'presets'];
|
||||
for (const field of uiFields) {
|
||||
expect(INTERFACE_PERMISSION_FIELDS.has(field)).toBe(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: false } },
|
||||
{ interface: { modelSelect: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: false } },
|
||||
{ interface: { modelSelect: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: true } },
|
||||
{ interface: { modelSelect: true } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: true } },
|
||||
{ interface: { modelSelect: true } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ describe('upsertConfig', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: false } },
|
||||
{ interface: { modelSelect: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -240,7 +240,7 @@ describe('patchConfigFields', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: true, sidePanel: true } },
|
||||
{ interface: { modelSelect: true, parameters: true } },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -248,14 +248,14 @@ describe('patchConfigFields', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ 'interface.endpointsMenu': false },
|
||||
{ 'interface.modelSelect': false },
|
||||
10,
|
||||
);
|
||||
|
||||
const overrides = result!.overrides as Record<string, unknown>;
|
||||
const iface = overrides.interface as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBe(false);
|
||||
expect(iface.sidePanel).toBe(true);
|
||||
expect(iface.modelSelect).toBe(false);
|
||||
expect(iface.parameters).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a config if none exists (upsert)', async () => {
|
||||
|
|
@ -263,7 +263,7 @@ describe('patchConfigFields', () => {
|
|||
PrincipalType.ROLE,
|
||||
'newrole',
|
||||
PrincipalModel.ROLE,
|
||||
{ 'interface.endpointsMenu': false },
|
||||
{ 'interface.modelSelect': false },
|
||||
10,
|
||||
);
|
||||
|
||||
|
|
@ -278,19 +278,19 @@ describe('unsetConfigField', () => {
|
|||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
PrincipalModel.ROLE,
|
||||
{ interface: { endpointsMenu: false, sidePanel: false } },
|
||||
{ interface: { modelSelect: false, parameters: false } },
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await methods.unsetConfigField(
|
||||
PrincipalType.ROLE,
|
||||
'admin',
|
||||
'interface.endpointsMenu',
|
||||
'interface.modelSelect',
|
||||
);
|
||||
const overrides = result!.overrides as Record<string, unknown>;
|
||||
const iface = overrides.interface as Record<string, unknown>;
|
||||
expect(iface.endpointsMenu).toBeUndefined();
|
||||
expect(iface.sidePanel).toBe(false);
|
||||
expect(iface.modelSelect).toBeUndefined();
|
||||
expect(iface.parameters).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for non-existent config', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
TEndpoint,
|
||||
FileSources,
|
||||
FileStorage,
|
||||
TFileConfig,
|
||||
TAzureConfig,
|
||||
TCustomConfig,
|
||||
|
|
@ -62,7 +62,7 @@ export interface AppConfig {
|
|||
/** Web search configuration */
|
||||
webSearch?: TCustomConfig['webSearch'];
|
||||
/** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */
|
||||
fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob;
|
||||
fileStrategy: FileStorage;
|
||||
/** File strategies configuration */
|
||||
fileStrategies?: TCustomConfig['fileStrategies'];
|
||||
/** Registration configurations */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue