diff --git a/e2e/config.spec.ts b/e2e/config.spec.ts
new file mode 100644
index 00000000..7ba66dd2
--- /dev/null
+++ b/e2e/config.spec.ts
@@ -0,0 +1,121 @@
+import { test, expect } from '@playwright/test';
+import AxeBuilder from '@axe-core/playwright';
+import type { Page } from '@playwright/test';
+
+const TABS = ['providers', 'custom', 'specs', 'mcp', 'features', 'files', 'system'];
+const TEXT_INPUTS =
+ 'form input[type=text]:visible:enabled, form input[type=number]:visible:enabled, form input:not([type]):visible:enabled';
+const MAX_INPUTS_PER_TAB = 30;
+
+async function openTab(page: Page, tab: string) {
+ await page.goto(`/configuration?tab=${tab}`);
+ await page.waitForLoadState('networkidle');
+}
+
+/** Opens collapsed sections and entry cards so their inputs can be reached (select triggers are left alone). */
+async function expandAll(page: Page) {
+ for (let round = 0; round < 2; round++) {
+ const toggles = await page
+ .locator('form button[aria-expanded=false]:not([aria-haspopup]):not([role=combobox])')
+ .all();
+ for (const toggle of toggles.slice(0, 12)) {
+ await toggle.click({ timeout: 1_000 }).catch(() => undefined);
+ }
+ }
+ const cards = await page.locator('form [role=button][aria-expanded=false]').all();
+ for (const card of cards.slice(0, 3)) await card.click({ timeout: 1_000 }).catch(() => undefined);
+}
+
+const unsavedBar = (page: Page) => page.getByText('Unsaved changes');
+
+test.describe('Configuration: Enter never edits another field', () => {
+ test.describe.configure({ timeout: 120_000 });
+
+ for (const tab of TABS) {
+ test(`Enter in each input on the ${tab} tab leaves the page clean`, async ({ page }) => {
+ await openTab(page, tab);
+ await expandAll(page);
+ await expect(unsavedBar(page)).toHaveCount(0);
+ const inputs = page.locator(TEXT_INPUTS);
+ const count = Math.min(await inputs.count(), MAX_INPUTS_PER_TAB);
+ const cardsBefore = await page.locator('form [role=button][aria-expanded]').count();
+ for (let i = 0; i < count; i++) {
+ const input = inputs.nth(i);
+ await input.focus();
+ await input.press('Enter');
+ await expect(unsavedBar(page), `Enter in input #${i} made the page dirty`).toHaveCount(0);
+ }
+ await expect(page.locator('form [role=button][aria-expanded]')).toHaveCount(cardsBefore);
+ });
+ }
+
+ test('Enter after editing a value marks only that field', async ({ page }) => {
+ await openTab(page, 'system');
+ const field = page.locator('#balance-startBalance');
+ await field.fill('424242');
+ await field.press('Enter');
+ await expect(unsavedBar(page)).toHaveCount(1);
+ await page
+ .getByRole('button', { name: /^Save$/ })
+ .last()
+ .click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toContainText('1 field will be updated');
+ await expect(dialog).toContainText('balance.startBalance');
+ await dialog.getByRole('button', { name: 'Cancel' }).click();
+ await page.getByRole('button', { name: 'Discard' }).click();
+ });
+
+ test('Enter in a create dialog still submits that dialog', async ({ page }) => {
+ await openTab(page, 'custom');
+ await page
+ .getByRole('button', { name: /Create endpoint/ })
+ .first()
+ .click();
+ const dialog = page.getByRole('dialog');
+ await dialog.locator('#create-endpoint-name').fill('Enter Endpoint');
+ await dialog.locator('#create-endpoint-apiKey').fill('key');
+ const baseURL = dialog.locator('#create-endpoint-baseURL');
+ await baseURL.fill('https://enter.example.com/v1');
+ await baseURL.press('Enter');
+ await expect(dialog).toHaveCount(0);
+ await expect(unsavedBar(page)).toHaveCount(1);
+ await page.getByRole('button', { name: 'Discard' }).click();
+ });
+});
+
+test.describe('Configuration: delete buttons', () => {
+ test('every delete button is announced as "Delete - "', async ({ page }) => {
+ await openTab(page, 'custom');
+ const deleteButtons = page.locator('form button.icon-btn-danger');
+ test.skip((await deleteButtons.count()) === 0, 'No custom endpoints to delete');
+ await expect(page.locator('form button[aria-label="trash"]')).toHaveCount(0);
+ for (const label of await deleteButtons.evaluateAll((els) =>
+ els.map((el) => el.getAttribute('aria-label') ?? ''),
+ )) {
+ expect(label).toMatch(/^Delete \S/);
+ }
+ const results = await new AxeBuilder({ page })
+ .include('form')
+ .withRules(['button-name'])
+ .analyze();
+ expect(results.violations).toEqual([]);
+ });
+
+ test('keyboard Enter on a card trash button deletes the entry without toggling the card', async ({
+ page,
+ }) => {
+ await openTab(page, 'custom');
+ const headers = page.locator('form [role=button][aria-expanded]');
+ const before = await headers.count();
+ test.skip(before === 0, 'No custom endpoints to delete');
+ const header = headers.first();
+ const trash = header.locator('button.icon-btn-danger');
+ await trash.focus();
+ await page.keyboard.press('Enter');
+ await expect(headers).toHaveCount(before - 1);
+ await expect(headers.first()).toHaveAttribute('aria-expanded', 'false');
+ await expect(unsavedBar(page)).toHaveCount(1);
+ await page.getByRole('button', { name: 'Discard' }).click();
+ });
+});
diff --git a/eslint.config.js b/eslint.config.js
index 650b4120..157db180 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -3,6 +3,13 @@ import tsPlugin from '@typescript-eslint/eslint-plugin';
import importPlugin from 'eslint-plugin-import-x';
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
import clickUiPlugin from 'eslint-plugin-click-ui';
+import requireButtonHtmlType from './tools/eslint-plugin-click-ui/rules/require-button-html-type.js';
+
+/** Loads rules from the in-repo plugin source so a stale installed copy cannot hide new rules. */
+const clickUi = {
+ ...clickUiPlugin,
+ rules: { ...clickUiPlugin.rules, 'require-button-html-type': requireButtonHtmlType },
+};
export default [
{
@@ -17,7 +24,7 @@ export default [
'@typescript-eslint': tsPlugin,
'import-x': importPlugin,
'tailwind-canonical-classes': tailwindCanonicalClasses,
- 'click-ui': clickUiPlugin,
+ 'click-ui': clickUi,
},
rules: {
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': false }],
@@ -53,6 +60,7 @@ export default [
...clickUiPlugin.configs.recommended.rules,
'click-ui/require-provider': 'off',
'click-ui/select-requires-options': 'off',
+ 'click-ui/require-button-html-type': 'error',
},
},
];
diff --git a/src/components/AuthCard.tsx b/src/components/AuthCard.tsx
index 756dd65c..eb11f62b 100644
--- a/src/components/AuthCard.tsx
+++ b/src/components/AuthCard.tsx
@@ -317,6 +317,7 @@ export function AuthCard({
/>
{roleDetail.isError && (
roleDetail.refetch()}
@@ -349,12 +350,14 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro
)}
{
+ if (!isEditingScope || !baseActiveConfigValues || !configValues) return baseActiveConfigValues;
+ const withBase = { ...baseActiveConfigValues };
+ for (const section of READ_ONLY_CONFIG_SECTIONS.keys()) {
+ if (section in configValues) withBase[section] = configValues[section];
+ }
+ return withBase;
+ }, [isEditingScope, baseActiveConfigValues, configValues]);
+
const activeConfigValues = useMemo(() => {
- if (!baseActiveConfigValues) return baseActiveConfigValues;
+ if (!displayConfigValues) return displayConfigValues;
const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k));
- if (indexedEdits.length === 0) return baseActiveConfigValues;
- return mergeIndexedArrayEdits(baseActiveConfigValues, indexedEdits);
- }, [baseActiveConfigValues, editedValues]);
+ if (indexedEdits.length === 0) return displayConfigValues;
+ return mergeIndexedArrayEdits(displayConfigValues, indexedEdits);
+ }, [displayConfigValues, editedValues]);
const scopeConfiguredPaths = useMemo(() => {
if (!scopeChangedPaths) return new Set();
@@ -411,8 +438,13 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return set;
}, [baseActiveConfigValues]);
+ const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
const handleFieldChange = useCallback(
(path: string, value: t.ConfigValue) => {
+ setSaveError(null);
setTouchedPaths((prev) => {
if (prev.has(path)) return prev;
const next = new Set(prev);
@@ -465,22 +497,26 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return resets;
}, [editedValues]);
- useBlocker({
- shouldBlockFn: ({ current, next }) => {
+ const shouldBlockNavigation = useCallback(
+ ({ current, next }: { current: { pathname: string }; next: { pathname: string } }) => {
if (!isDirty) return false;
if (current.pathname === next.pathname) return false;
return !window.confirm(localize('com_config_unsaved_leave'));
},
- });
+ [isDirty, localize],
+ );
+ const shouldPromptBeforeUnload = useCallback(() => isDirty, [isDirty]);
- const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
- const [saving, setSaving] = useState(false);
- const [saveError, setSaveError] = useState(null);
+ useBlocker({
+ shouldBlockFn: shouldBlockNavigation,
+ enableBeforeUnload: shouldPromptBeforeUnload,
+ });
const handleDiscard = useCallback(() => {
setEditedValues({});
setTouchedPaths(new Set());
setEditSessionId((id) => id + 1);
+ setSaveError(null);
}, []);
const clearEdits = useCallback(() => {
@@ -490,26 +526,106 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
setConfirmSaveOpen(false);
setSaving(false);
setSaveError(null);
- notifySuccess(localize('com_config_saved'));
- }, [localize]);
+ }, []);
- const invalidateAndResetBase = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ['baseConfig'] });
- clearEdits();
- }, [queryClient, clearEdits]);
+ /**
+ * Toast for a finished write. When anything was not stored, a warning lists
+ * each path with the reason, and only claims a save if something was written.
+ */
+ const announceWrite = useCallback(
+ (skipped: t.SkippedChange[], written: number, successMessage: string) => {
+ if (skipped.length === 0) {
+ notifySuccess(successMessage);
+ return;
+ }
+ const details = skipped
+ .map((s) => `${s.fieldPath}: ${localize(SKIP_REASON_KEYS[s.reason])}`)
+ .join('\n');
+ const titleKey = written > 0 ? 'com_config_saved_with_skipped' : 'com_config_nothing_stored';
+ notifyWarning(localize(titleKey, { count: skipped.length }), details);
+ },
+ [localize],
+ );
- const invalidateAndResetScope = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] });
- queryClient.invalidateQueries({ queryKey: ['profileMap'] });
- queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
- clearEdits();
- }, [queryClient, clearEdits]);
-
- const importMutation = useMutation({
- mutationFn: (config: Record) => importBaseConfigFn({ data: { config } }),
- onError: (err: Error) => notifyError(err.message),
- onSuccess: invalidateAndResetBase,
- });
+ const invalidateTarget = useCallback(
+ (target: t.WriteTarget) => {
+ if (target.type === 'base') {
+ return queryClient.invalidateQueries({ queryKey: ['baseConfig'] });
+ }
+ return Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }),
+ queryClient.invalidateQueries({ queryKey: ['profileMap'] }),
+ queryClient.invalidateQueries({ queryKey: ['availableScopes'] }),
+ ]);
+ },
+ [queryClient],
+ );
+
+ /**
+ * The one write path for saves and imports. Every entry is validated before
+ * anything is written, so a rejected batch never leaves resets half-applied;
+ * queries are refetched afterwards whether or not the write succeeded, so
+ * the page always shows what was actually persisted.
+ */
+ const writeChanges = useCallback(
+ async (
+ target: t.WriteTarget,
+ saves: t.SaveEntry[],
+ resets: string[],
+ inheritedMcpKeys: Set = new Set(),
+ ): Promise => {
+ try {
+ const check = await validateConfigChangesFn({
+ data: { target: target.type, saves, resets },
+ });
+ if (check.errors.length > 0) {
+ throw new Error(
+ `${localize('com_config_validation_failed')} — ${check.errors.map((e) => e.error).join('; ')}`,
+ );
+ }
+ const skippedPaths = new Set(check.skipped.map((s) => s.fieldPath));
+ const writableResets = resets.filter((p) => !skippedPaths.has(p));
+ const writableSaves = saves.filter((e) => !skippedPaths.has(e.fieldPath));
+
+ /** Resets land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */
+ if (target.type === 'scope') {
+ const { principalType, principalId } = target.scope;
+ const { resetPaths, tombstonePaths } = partitionScopeResetPaths(
+ writableResets,
+ inheritedMcpKeys,
+ );
+ await Promise.all([
+ ...resetPaths.map((fieldPath) =>
+ removeFieldProfileValueFn({ data: { fieldPath, principalType, principalId } }),
+ ),
+ ...tombstonePaths.map((fieldPath) =>
+ tombstoneFieldProfileValueFn({ data: { fieldPath, principalType, principalId } }),
+ ),
+ ]);
+ } else {
+ await Promise.all(
+ writableResets.map((fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } })),
+ );
+ }
+
+ if (writableSaves.length === 0) return check.skipped;
+ const result =
+ target.type === 'scope'
+ ? await bulkSaveProfileValuesFn({
+ data: {
+ principalType: target.scope.principalType,
+ principalId: target.scope.principalId,
+ entries: writableSaves,
+ },
+ })
+ : await saveBaseConfigFn({ data: { entries: writableSaves } });
+ return [...check.skipped, ...result.skipped];
+ } finally {
+ await invalidateTarget(target);
+ }
+ },
+ [localize, invalidateTarget],
+ );
const [resetBaseOpen, setResetBaseOpen] = useState(false);
const [resettingBase, setResettingBase] = useState(false);
@@ -544,6 +660,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
}, [resettingBase, queryClient, localize]);
const handleResetField = useCallback((fieldPath: string) => {
+ setSaveError(null);
startTransition(() => {
setTouchedPaths((prev) => {
if (prev.has(fieldPath)) return prev;
@@ -555,10 +672,49 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
});
}, []);
+ const overridePaths = isEditingScope ? scopeConfiguredPaths : dbOverridePaths;
+
+ const saveContext = useMemo(
+ () => ({
+ fields: schemaTree,
+ baselineAt: (path) => getValueAtPath(baseActiveConfigValues, path),
+ hasOverride: (path) => hasPathOverlap(path, overridePaths),
+ }),
+ [schemaTree, baseActiveConfigValues, overridePaths],
+ );
+
+ /** What Save would actually send: normalized, unchanged edits dropped, read-only paths skipped. */
+ const savePlan = useMemo(
+ () => buildSavePayload(touchedPaths, editedValues, schemaPathSet, saveContext),
+ [touchedPaths, editedValues, schemaPathSet, saveContext],
+ );
+
+ const unsafeKeys = useMemo(
+ () =>
+ BLOCK_UNSAFE_CONFIG_KEYS
+ ? savePlan.saves.flatMap((entry) => findUnsafeKeys(entry.value, entry.fieldPath))
+ : [],
+ [savePlan],
+ );
+
+ const editTarget = useMemo(
+ () =>
+ isEditingScope && editingScope ? { type: 'scope', scope: editingScope } : { type: 'base' },
+ [isEditingScope, editingScope],
+ );
+
const handleConfirmSave = useCallback(async () => {
- if (saving) return;
- const { touched, saves, resets } = buildSavePayload(touchedPaths, editedValues, schemaPathSet);
- if (touched.length === 0) return;
+ if (saving || unsafeKeys.length > 0) return;
+ const { touched, saves, resets, skipped } = savePlan;
+ if (saves.length === 0 && resets.length === 0) {
+ clearEdits();
+ if (skipped.length > 0) {
+ announceWrite(skipped, 0, localize('com_config_saved'));
+ } else {
+ notifyInfo(localize('com_config_nothing_to_save'));
+ }
+ return;
+ }
/** Per-leaf saves can land an MCP entry in a transport state whose required siblings are missing (e.g. type=stdio with no command/args). Server-side per-field validation only sees one path at a time, so do the cross-field check here against the merged effective entry before any PATCH fires. Use baseActiveConfigValues so scope-mode edits validate against the scope-resolved baseline (where prior scope overrides supply some required fields) instead of the base config alone. */
const mcpBaseline = (() => {
@@ -568,9 +724,11 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
}
return {};
})();
+ const savedValues = new Map(saves.map((e) => [e.fieldPath, e.value]));
+ const resetSet = new Set(resets);
const mcpEdits: Array<[string, t.ConfigValue]> = touched
- .filter((p) => p.startsWith('mcpServers.'))
- .map((p) => [p, editedValues[p]] as [string, t.ConfigValue]);
+ .filter((p) => p.startsWith('mcpServers.') && (savedValues.has(p) || resetSet.has(p)))
+ .map((p) => [p, savedValues.get(p)]);
/** A leaf reset (undefined write) removes the override and reveals the value of the next-lower layer. In scope mode that next layer is the base config; in base mode it is the un-merged YAML config (the baseOnly response). Feed whichever layer applies as the resetFallback so the cross-field validator does not falsely flag a reset-but-still-valid field as missing. */
const mcpResetFallback = (() => {
const source = isEditingScope ? configValues?.mcpServers : baseConfigData?.yamlMcpServers;
@@ -605,60 +763,10 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
setSaveError(null);
try {
- /** Resets must land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */
- if (resets.length > 0) {
- const resetPromises = isEditingScope
- ? (() => {
- const { resetPaths, tombstonePaths } = partitionScopeResetPaths(
- resets,
- inheritedMcpKeys,
- );
- return [
- ...resetPaths.map((fieldPath) =>
- removeFieldProfileValueFn({
- data: {
- fieldPath,
- principalType: editingScope!.principalType,
- principalId: editingScope!.principalId,
- },
- }),
- ),
- ...tombstonePaths.map((fieldPath) =>
- tombstoneFieldProfileValueFn({
- data: {
- fieldPath,
- principalType: editingScope!.principalType,
- principalId: editingScope!.principalId,
- },
- }),
- ),
- ];
- })()
- : resets.map((fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } }));
- if (resetPromises.length > 0) {
- await Promise.all(resetPromises);
- }
- }
-
- if (saves.length > 0) {
- if (isEditingScope) {
- await bulkSaveProfileValuesFn({
- data: {
- principalType: editingScope!.principalType,
- principalId: editingScope!.principalId,
- entries: saves,
- },
- });
- } else {
- await saveBaseConfigFn({ data: { entries: saves } });
- }
- }
-
- if (isEditingScope) {
- invalidateAndResetScope();
- } else {
- invalidateAndResetBase();
- }
+ const writeSkipped = await writeChanges(editTarget, saves, resets, inheritedMcpKeys);
+ const written = Math.max(0, saves.length + resets.length - writeSkipped.length);
+ clearEdits();
+ announceWrite([...skipped, ...writeSkipped], written, localize('com_config_saved'));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSaving(false);
@@ -666,52 +774,61 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
notifyError(message);
}
}, [
- touchedPaths,
- editedValues,
- schemaPathSet,
saving,
- isEditingScope,
+ unsafeKeys,
+ savePlan,
+ clearEdits,
+ announceWrite,
baseActiveConfigValues,
+ isEditingScope,
configValues,
baseConfigData,
localize,
- editingScope,
- invalidateAndResetScope,
- invalidateAndResetBase,
+ writeChanges,
+ editTarget,
]);
- const serializedEditedValues = useMemo(() => {
+ /** The review lists exactly what Save sends: a value for each save, "removed" for each reset. */
+ const reviewValues = useMemo(() => {
const result: t.FlatConfigMap = {};
- for (const [k, v] of Object.entries(editedValues)) {
- result[k] = stripSecretPreviewValues(deepSerializeKVPairs(v), k, schemaPathSet);
- }
+ for (const { fieldPath, value } of savePlan.saves) result[fieldPath] = value;
+ for (const fieldPath of savePlan.resets) result[fieldPath] = undefined;
return result;
- }, [editedValues, schemaPathSet]);
+ }, [savePlan]);
+ /**
+ * BEFORE values come from the target being edited (the open profile's own
+ * values in scope mode). A reset removes the target's own override, so its
+ * BEFORE is that stored override, not the merged value (which for a
+ * YAML-managed field such as Azure groups is the librechat.yaml one).
+ */
const originalValuesForDialog = useMemo(() => {
- const baseline = isEditingScope ? scopeBaseline : flatBaseline;
- const result: t.FlatConfigMap = { ...baseline };
- for (const path of Object.keys(editedValues)) {
- if (path in result) {
- result[path] = stripSecretPreviewValues(result[path], path, schemaPathSet);
- continue;
- }
- const segments = path.split('.');
- let current: t.ConfigValue = configValues;
- for (const seg of segments) {
- if (current == null || typeof current !== 'object') {
- current = undefined;
- break;
- }
- current = Array.isArray(current)
- ? (current as t.ConfigValue[])[Number(seg)]
- : (current as Record)[seg];
+ const result: t.FlatConfigMap = {};
+ const ownValues = isEditingScope ? baseActiveConfigValues : dbOverrides;
+ for (const path of Object.keys(reviewValues)) {
+ const before =
+ (reviewValues[path] === undefined ? getValueAtPath(ownValues, path) : undefined) ??
+ getValueAtPath(baseActiveConfigValues, path);
+ if (before !== undefined) {
+ result[path] = stripSecretPreviewValues(before, path, schemaPathSet);
}
- if (current !== undefined)
- result[path] = stripSecretPreviewValues(current, path, schemaPathSet);
}
return result;
- }, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues, schemaPathSet]);
+ }, [reviewValues, isEditingScope, baseActiveConfigValues, dbOverrides, schemaPathSet]);
+
+ /**
+ * What each reset falls back to: the librechat.yaml value for base (or the
+ * schema default when the YAML does not set it), the base value for a profile.
+ */
+ const revertValuesForDialog = useMemo(() => {
+ const result: t.FlatConfigMap = {};
+ const fallback = isEditingScope ? configValues : baseConfigData?.yamlConfig;
+ for (const path of savePlan.resets) {
+ const value = getValueAtPath(fallback, path) ?? schemaDefaults[path];
+ result[path] = stripSecretPreviewValues(value, path, schemaPathSet);
+ }
+ return result;
+ }, [savePlan, isEditingScope, configValues, baseConfigData, schemaDefaults, schemaPathSet]);
const [importSuccessMessage, setImportSuccessMessage] = useState(null);
@@ -722,63 +839,46 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
dismissTimer.current = setTimeout(() => setImportSuccess(false), 4000);
}, []);
- const handleImportAsProfile = useCallback(
- async (appConfig: Record, scope: t.ConfigScope) => {
- const normalized = normalizeImportConfig(appConfig);
- const flat = flattenObject(normalized);
- const entries = Object.entries(flat)
- .filter(
- ([fieldPath, value]) =>
- value != null && secretPathForPreviewPath(fieldPath, schemaPathSet) == null,
- )
- .map(([fieldPath, value]) => ({
- fieldPath,
- value: stripSecretPreviewValues(value, fieldPath, schemaPathSet),
- }));
- await bulkSaveProfileValuesFn({
- data: {
- principalType: scope.principalType,
- principalId: scope.principalId,
- entries,
- },
- });
- queryClient.invalidateQueries({ queryKey: ['profileMap'] });
- queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] });
- queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
- queryClient.invalidateQueries({ queryKey: ['roles'] });
- queryClient.invalidateQueries({ queryKey: ['groups'] });
- showImportSuccess(
- localize('com_config_import_profile_success', {
- count: entries.length,
- name: scope.name,
- }),
- );
- },
- [queryClient, localize, showImportSuccess, schemaPathSet],
- );
-
+ /**
+ * Imports merge into the chosen target through the same validated write
+ * path as Save: only the keys present in the YAML are written (normalized
+ * like an edit, so blank values are left out), and nothing else is replaced.
+ */
const handleImport = useCallback(
- (appConfig: Record) => {
- const normalized = normalizeImportConfig(appConfig);
- if (isEditingScope && editingScope) {
- handleImportAsProfile(normalized, editingScope).catch((err: Error) => {
- notifyError(err.message);
- });
- } else {
- const stripped = stripSecretPreviewValues(normalized, '', schemaPathSet) as Record<
- string,
- t.ConfigValue
- >;
- importMutation.mutate(stripped, { onSuccess: () => showImportSuccess() });
+ async (appConfig: t.ConfigRecord, target: t.WriteTarget) => {
+ const { entries, skipped: blank } = collectImportEntries(
+ appConfig,
+ schemaTree,
+ schemaPathSet,
+ );
+ if (entries.length === 0 && blank.length === 0) {
+ throw new Error(localize('com_config_import_nothing'));
}
+ const writeSkipped = entries.length > 0 ? await writeChanges(target, entries, []) : [];
+ if (target.type === 'scope') {
+ queryClient.invalidateQueries({ queryKey: ['roles'] });
+ queryClient.invalidateQueries({ queryKey: ['groups'] });
+ }
+ const written = Math.max(0, entries.length - writeSkipped.length);
+ const message =
+ target.type === 'scope'
+ ? localize('com_config_import_profile_success', {
+ count: written,
+ name: target.scope.name,
+ })
+ : localize('com_config_import_base_success', { count: written });
+ if (written > 0) showImportSuccess(message);
+ const skipped = [...blank, ...writeSkipped];
+ if (skipped.length > 0) announceWrite(skipped, written, message);
},
[
- isEditingScope,
- editingScope,
- importMutation,
- showImportSuccess,
- handleImportAsProfile,
+ schemaTree,
schemaPathSet,
+ localize,
+ writeChanges,
+ queryClient,
+ showImportSuccess,
+ announceWrite,
],
);
@@ -823,15 +923,26 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
const children = section.children ?? [];
const hasStructuredChildren =
(section.isObject || section.type === 'record') && children.length > 0;
+ const readOnlyReason = READ_ONLY_CONFIG_SECTIONS.get(section.key);
return {
id: section.key,
titleKey: meta?.titleKey ?? `com_config_section_${section.key}`,
+ title: meta ? undefined : splitCamelCase(section.key).join(' '),
+ isRecord: section.type === 'record',
descriptionKey: meta?.descriptionKey,
fields: hasStructuredChildren ? children : [],
...(!hasStructuredChildren && { sectionField: section }),
...(section.key === 'interface' && {
bannerText: localize('com_config_interface_permissions_info'),
}),
+ ...(readOnlyReason && {
+ readOnlyReason,
+ bannerText: localize(
+ readOnlyReason === 'baseOnly'
+ ? 'com_config_base_only_section'
+ : 'com_config_yaml_only_section',
+ ),
+ }),
};
});
@@ -930,6 +1041,16 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return allSections;
}, [schemaTree, activeTab, activeConfigValues, localize, sectionPermissions]);
+ /** Top-level settings the bundled schema doesn't know (newer LibreChat), listed read-only on System. */
+ const topLevelUnknown = useMemo(() => {
+ if (activeTab !== 'system' || !activeConfigValues) return undefined;
+ const value: Record = {};
+ for (const [key, entry] of Object.entries(activeConfigValues)) {
+ if (!APP_SERVICE_INTERNAL_KEYS.has(key)) value[key] = entry;
+ }
+ return { fields: schemaTree, value };
+ }, [activeTab, activeConfigValues, schemaTree]);
+
const renderBanner = () => {
if (importSuccess) {
return (
@@ -1027,6 +1148,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
baseRecordKeys={baseRecordKeys}
onValidationError={(message) => notifyError(message)}
editSessionId={editSessionId}
+ unknownSettings={topLevelUnknown}
/>
@@ -1046,18 +1168,32 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
discardLabel={localize('com_config_discard')}
saveLabel={localize('com_config_save')}
onDiscard={handleDiscard}
- onSave={() => setConfirmSaveOpen(true)}
+ onSave={() => {
+ setSaveError(null);
+ setConfirmSaveOpen(true);
+ }}
/>
)}
setConfirmSaveOpen(false)}
+ onCancel={() => {
+ setConfirmSaveOpen(false);
+ setSaveError(null);
+ }}
/>
setImportOpen(false)}
+ currentScope={editingScope}
onImport={handleImport}
- onImportAsProfile={handleImportAsProfile}
/>
) {
+ const { target } = event;
+ if (event.key !== 'Enter' || !(target instanceof HTMLInputElement)) return;
+ if (event.currentTarget.contains(target)) event.preventDefault();
+}
+
function isSimpleScalar(f: t.SchemaField): boolean {
return !f.children?.length && !f.isArray && f.type !== 'record' && f.type !== 'object';
}
@@ -59,20 +73,13 @@ export function ConfigTabContent({
baseRecordKeys,
onValidationError,
editSessionId,
+ unknownSettings,
}: t.ConfigTabContentProps) {
const localize = useLocalize();
const fieldsDisabled = readOnly;
- const getValue = (path: string, fallback: t.ConfigValue): t.ConfigValue => {
- if (path in editedValues) {
- if (editedValues[path] === undefined) return schemaDefaults?.[path] ?? fallback;
- return editedValues[path];
- }
- if (resolvedValues && path in resolvedValues) {
- return resolvedValues[path];
- }
- return fallback;
- };
+ const getValue = (path: string, fallback: t.ConfigValue): t.ConfigValue =>
+ resolveFieldValue(path, fallback, editedValues, resolvedValues, schemaDefaults);
const filtering = showChangedOnly;
@@ -159,7 +166,9 @@ export function ConfigTabContent({
const renderSectionContent = (section: t.ConfigSectionConfig) => {
const dataKey = section.schemaKey ?? section.id;
const sectionValue = configValues?.[dataKey];
- const sectionDisabled = isSectionDisabled(!!fieldsDisabled, sectionPermissions, dataKey);
+ const sectionDisabled =
+ section.readOnlyReason != null ||
+ isSectionDisabled(!!fieldsDisabled, sectionPermissions, dataKey);
const CustomSectionRenderer =
section.fields.length > 0 ? SECTION_RENDERERS[section.id] : undefined;
const fieldRendererProps: t.FieldRendererProps = {
@@ -195,7 +204,7 @@ export function ConfigTabContent({
<>
{section.bannerText && (
-
+
)}
{section.fields.length > 0 &&
@@ -204,6 +213,9 @@ export function ConfigTabContent({
) : (
))}
+ {section.fields.length > 0 && !CustomSectionRenderer && !section.isRecord && (
+
+ )}
{section.fields.length === 0 && section.sectionField && (
e.preventDefault()}
+ onKeyDown={preventImplicitSubmit}
className="flex flex-col gap-6 py-4"
>
+
{groups.map((group) => {
if (group.kind === 'flat') {
return (
@@ -290,7 +304,7 @@ export function ConfigTabContent({
{renderSectionContent(section)}
@@ -326,6 +340,9 @@ export function ConfigTabContent({
);
})}
+ {unknownSettings && (
+
+ )}
);
}
diff --git a/src/components/configuration/ConfigTableOfContents.tsx b/src/components/configuration/ConfigTableOfContents.tsx
index 23813366..d49eb854 100644
--- a/src/components/configuration/ConfigTableOfContents.tsx
+++ b/src/components/configuration/ConfigTableOfContents.tsx
@@ -184,7 +184,7 @@ export const ConfigTableOfContents = memo(function ConfigTableOfContents({
onClick={() => handleClick(sectionDomId)}
className="toc-item toc-item-parent block w-full cursor-pointer truncate border-none bg-transparent py-1.5 pr-1 pl-3 text-left text-[13px] font-medium text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
>
- {localize(section.titleKey)}
+ {section.title ?? localize(section.titleKey)}
{tocItems && tocItems.length > 0 && (