diff --git a/e2e/a11y/config.spec.ts b/e2e/a11y/config.spec.ts index 065822bf..7293268b 100644 --- a/e2e/a11y/config.spec.ts +++ b/e2e/a11y/config.spec.ts @@ -102,6 +102,21 @@ for (const theme of THEMES) { expect(await dialogViolations(page)).toEqual([]); }); + test('the change review of a spec list taking over librechat.yaml (its warning text)', async ({ + page, + }) => { + await openConfigTab(page, 'specs'); + const card = page.locator('[data-entry-card]').first(); + test.skip((await card.count()) === 0, 'No model specs'); + await card.locator('button[aria-expanded]').first().click(); + const label = page.locator('#modelSpecs-list-0-label'); + await label.fill(`${await label.inputValue()} (review)`); + await label.press('Tab'); + await page.getByRole('button', { name: 'Review & save' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + expect(await dialogViolations(page)).toEqual([]); + }); + test('the import wizard', async ({ page }) => { await openConfigTab(page, 'system'); await page.getByRole('button', { name: 'Import YAML' }).click(); @@ -111,6 +126,84 @@ for (const theme of THEMES) { }); } +/** The Wave 2 editors' overlays at phone width, where their bodies scroll. */ +for (const theme of THEMES) { + test.describe(`axe on editor overlays: ${theme} 390px`, () => { + test.use({ colorScheme: theme, viewport: { width: 390, height: 844 } }); + + test('the Azure group details drawer', async ({ page }) => { + await openConfigTab(page, 'providers'); + await page.locator('#section-endpoints\\.azureOpenAI .settings-section-trigger').click(); + /** The group with the longest model list, so the drawer body scrolls at this height. */ + const counts = await page + .locator('.azure-groups-table tbody tr .azure-col-number') + .allInnerTexts(); + const longest = counts.reduce( + (best, text, i) => (Number(text) > Number(counts[best]) ? i : best), + 0, + ); + await page + .getByRole('button', { name: /^Details of Azure group/ }) + .nth(longest) + .click(); + await expect(page.getByRole('dialog')).toBeVisible(); + expect(await dialogViolations(page)).toEqual([]); + }); + + test('the custom endpoint editor drawer', async ({ page }) => { + await openConfigTab(page, 'custom'); + await page.locator('li[data-endpoint-row] button[aria-haspopup="dialog"]').first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + expect(await dialogViolations(page)).toEqual([]); + }); + + test('Create endpoint', async ({ page }) => { + await openConfigTab(page, 'custom'); + await page.getByRole('button', { name: 'Create endpoint' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + expect(await dialogViolations(page)).toEqual([]); + }); + + test('Create MCP server', async ({ page }) => { + await openConfigTab(page, 'mcp'); + await page.getByRole('button', { name: 'Create MCP server' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + expect(await dialogViolations(page)).toEqual([]); + }); + }); +} + +test.describe('row action menus', () => { + test.use({ colorScheme: 'light', viewport: { width: 1440, height: 900 } }); + + test('custom: an action that opens the editor leaves focus in it', async ({ page }) => { + await openConfigTab(page, 'custom'); + const trigger = page.getByRole('button', { name: /^Actions for / }).first(); + await trigger.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('menu')).toBeVisible(); + await page.keyboard.press('Enter'); + const drawer = page.getByRole('dialog'); + await expect(drawer).toBeVisible(); + await expect + .poll(() => drawer.evaluate((el) => el.contains(document.activeElement))) + .toBe(true); + }); + + for (const tab of ['custom', 'mcp'] as const) { + test(`${tab}: Escape returns focus to the menu button`, async ({ page }) => { + await openConfigTab(page, tab); + const trigger = page.getByRole('button', { name: /^Actions for / }).first(); + await trigger.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('menu')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('menu')).toBeHidden(); + await expect(trigger).toBeFocused(); + }); + } +}); + test.describe('settings layout invariants (1440px, light)', () => { test.use({ colorScheme: 'light', @@ -218,3 +311,55 @@ for (const variant of VISUAL_VARIANTS) { } }); } + +test.describe('dialogs give focus back to what opened them', () => { + test.use({ colorScheme: 'light', viewport: { width: 1440, height: 900 } }); + + test('Create MCP server, closed with Escape', async ({ page }) => { + await openConfigTab(page, 'mcp'); + const create = page.getByRole('button', { name: 'Create MCP server' }); + await create.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(create).toBeFocused(); + }); + + test('the change review, closed with Escape', async ({ page }) => { + await openConfigTab(page, 'system'); + const field = page.locator('#balance-refillAmount'); + await field.fill('12345'); + await field.press('Tab'); + const review = page.getByRole('button', { name: 'Review & save' }); + await review.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(review).toBeFocused(); + }); +}); + +test.describe('custom endpoint drawer', () => { + test.use({ colorScheme: 'light', viewport: { width: 1440, height: 900 } }); + + test('a section link scrolls the drawer to its section', async ({ page }) => { + await openConfigTab(page, 'custom'); + await page.locator('li[data-endpoint-row] button[aria-haspopup="dialog"]').first().click(); + const drawer = page.locator('[data-endpoint-drawer]'); + await expect(drawer).toBeVisible(); + const links = drawer.locator('nav a'); + await links.last().click(); + await expect(links.last()).toHaveAttribute('aria-current', 'location'); + const scrolled = () => + drawer.locator('nav').evaluate((nav) => { + let node = nav.parentElement; + while (node && !/(auto|scroll)/.test(getComputedStyle(node).overflowY)) { + node = node.parentElement; + } + return node?.scrollTop ?? 0; + }); + await expect.poll(scrolled).toBeGreaterThan(0); + }); +}); diff --git a/e2e/a11y/config.spec.ts-snapshots/custom-dark-1440-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/custom-dark-1440-chromium-linux.png index 54b17c45..62f3b856 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/custom-dark-1440-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/custom-dark-1440-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/custom-light-1440-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/custom-light-1440-chromium-linux.png index fbeae19f..6dbb0dda 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/custom-light-1440-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/custom-light-1440-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/custom-light-390-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/custom-light-390-chromium-linux.png index 33ac6c92..0f0af2a9 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/custom-light-390-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/custom-light-390-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/features-dark-1440-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/features-dark-1440-chromium-linux.png index d9fd0171..124b7cb0 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/features-dark-1440-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/features-dark-1440-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/features-light-1440-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/features-light-1440-chromium-linux.png index afe04917..ece08cc0 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/features-light-1440-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/features-light-1440-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/features-light-390-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/features-light-390-chromium-linux.png index dbb6f629..8960c1e6 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/features-light-390-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/features-light-390-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/system-dark-1440-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/system-dark-1440-chromium-linux.png index 7017c2f8..7da6bc62 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/system-dark-1440-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/system-dark-1440-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/system-light-1440-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/system-light-1440-chromium-linux.png index 80aabd7b..615ab77c 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/system-light-1440-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/system-light-1440-chromium-linux.png differ diff --git a/e2e/a11y/config.spec.ts-snapshots/system-light-390-chromium-linux.png b/e2e/a11y/config.spec.ts-snapshots/system-light-390-chromium-linux.png index 9b2ac053..86fc9a61 100644 Binary files a/e2e/a11y/config.spec.ts-snapshots/system-light-390-chromium-linux.png and b/e2e/a11y/config.spec.ts-snapshots/system-light-390-chromium-linux.png differ diff --git a/e2e/config-shell.spec.ts b/e2e/config-shell.spec.ts index bf10c6e6..acb90d4e 100644 --- a/e2e/config-shell.spec.ts +++ b/e2e/config-shell.spec.ts @@ -14,8 +14,9 @@ test.skip(!LIVE, LIVE_SKIP_REASON); const TABS = ['providers', 'custom', 'specs', 'mcp', 'features', 'files', 'system']; /** Collapsed sections, groups and cards (not a model chip's inline editor or a select). */ +/** Collapsed sections, groups and cards (not menus, selects, tabs, or a chip's "why" disclosure). */ const COLLAPSED = - 'form [aria-expanded="false"]:not([aria-haspopup]):not([role=combobox]):not([role=tab]):not(.icon-btn)'; + 'form [aria-expanded="false"]:not([aria-haspopup]):not([role=combobox]):not([role=tab]):not(.icon-btn):not(.state-chip-link)'; async function openTab(page: Page, tab: string, extra = '') { await page.goto(`/configuration?tab=${tab}${extra}`); diff --git a/e2e/import.spec.ts b/e2e/import.spec.ts index 1fe470b1..505ca46a 100644 --- a/e2e/import.spec.ts +++ b/e2e/import.spec.ts @@ -82,7 +82,7 @@ test('imports into a role that has no profile yet', async ({ page }) => { const admin = page.getByRole('radio', { name: /ADMIN/ }); await admin.click(); const { dialog, apply } = await previewAndApply(page, /into Role: ADMIN$/); - await expect(dialog.getByText('Custom welcome')).toBeVisible(); + await expect(dialog.getByText('Welcome message')).toBeVisible(); await apply.click(); await expect(page.getByText(/Imported 1 value into Role: ADMIN/).first()).toBeVisible(); }); diff --git a/e2e/specs.spec.ts b/e2e/specs.spec.ts index 85943b57..71b9d2b8 100644 --- a/e2e/specs.spec.ts +++ b/e2e/specs.spec.ts @@ -46,16 +46,16 @@ async function revertList(page: Page) { await saveAndWait(page); } -/** Adds a spec with the four required fields, and returns its card. */ +/** Adds a spec with the four required fields (it opens on Endpoint, then Model), and returns its card. */ async function addSpec(page: Page, label: string, endpoint: string, model: string) { await page.getByRole('button', { name: 'Add spec' }).click(); const card = cards(page).last(); - await expect(card.getByLabel(/^Label\*?$/)).toBeFocused(); - await card.getByLabel(/^Label\*?$/).fill(label); - await card.getByLabel(/^Label\*?$/).press('Tab'); + await expect(card.getByLabel(/^Endpoint\*?$/)).toBeFocused(); await card.getByLabel(/^Endpoint\*?$/).selectOption(endpoint); await card.getByRole('combobox', { name: /^Model\*?$/ }).fill(model); await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Tab'); + await card.getByLabel(/^Label\*?$/).fill(label); + await card.getByLabel(/^Label\*?$/).press('Tab'); return card; } @@ -180,13 +180,15 @@ test.describe('Model specs', () => { page, }) => { await openSpecs(page, '&scope=role:USER'); - const customize = page.getByRole('button', { name: /^Customize list for/ }); + const customize = page.getByRole('button', { name: /^Override for Role: USER/ }); test.skip((await customize.count()) === 0, 'Needs a USER role profile without its own list'); await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0); await expect(cards(page).first()).toBeVisible(); await customize.click(); await expect(page.getByRole('button', { name: 'Add spec' })).toBeVisible(); await expect(page.getByText(/replaces Base's list/)).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0); }); test('the tab filter finds spec fields and phone width has no sideways scroll', async ({ diff --git a/src/components/access/GroupsTab.tsx b/src/components/access/GroupsTab.tsx index 11c5658f..9b931569 100644 --- a/src/components/access/GroupsTab.tsx +++ b/src/components/access/GroupsTab.tsx @@ -115,7 +115,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { diff --git a/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx b/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx index 4e60b27f..8cad8e66 100644 --- a/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx +++ b/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx @@ -193,6 +193,35 @@ describe('CustomEndpointsRenderer', () => { expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', [...remaining, saved[2]]); }); + it('keeps a pending deletion the list filter matches listed, with Undo', () => { + const layers = baseLayers(); + const saved = savedList(layers); + const extra = ['E1', 'E2', 'E3', 'E4'].map((name) => ({ ...ADMIN_ENTRY, name })); + const remaining = [...saved.slice(0, 2), ...extra]; + const edits: t.CollectionEdits = { + 'endpoints.custom': { + baseline: saved, + origins: remaining.map((entry, i) => + i < 2 ? { identity: ['GPT', 'Claude'][i], baseline: entry } : null, + ), + detached: [{ value: saved[2], origin: { identity: 'Mine', baseline: saved[2] } }], + }, + }; + render(); + fireEvent.change(screen.getByLabelText('com_config_ce_filter_a11y'), { + target: { value: 'mine' }, + }); + expect( + screen.getByRole('button', { name: 'com_config_ctl_entry_undo_a11y:Mine' }), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('com_config_ce_filter_a11y'), { + target: { value: 'E1' }, + }); + expect( + screen.queryByRole('button', { name: 'com_config_ctl_entry_undo_a11y:Mine' }), + ).not.toBeInTheDocument(); + }); + it('restores an undone deletion relative to the surviving endpoints', () => { const onChange = vi.fn(); const entry = (name: string): t.ConfigRecord => ({ ...ADMIN_ENTRY, name }); diff --git a/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx b/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx index 7f46d058..d13d6fba 100644 --- a/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx +++ b/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx @@ -3,10 +3,12 @@ import { configSchema, paramSettings } from 'librechat-data-provider'; import { describe, it, expect, vi, beforeAll } from 'vitest'; import { render, screen, fireEvent, within } from '@testing-library/react'; import type * as t from '@/types'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; import { HeaderPlaceholders, TagListField } from '../Request'; import { ParamDefinitionsRow } from '../ParamDefinitions'; import { extractSchemaTree } from '@/server/config'; import { KeyValueField } from '../../../fields'; +import { EndpointRefRow } from '../Fields'; import { PricingRow } from '../Pricing'; vi.mock('@/hooks/useLocalize', () => { @@ -226,3 +228,27 @@ describe('PricingRow', () => { expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ 'model-c': {} })); }); }); + +describe('EndpointRefRow', () => { + it('leaves out the endpoint being edited by the name LibreChat serves it under', () => { + const field = custom?.children?.find((child) => child.key === 'titleEndpoint'); + if (!field) throw new Error('no titleEndpoint field'); + const configValues = { endpoints: { custom: [{ name: 'Ollama' }, { name: 'Moonshot' }] } }; + render( + + + , + ); + const values = screen.getAllByRole('option').map((option) => option.getAttribute('value')); + expect(values).toContain('Moonshot'); + expect(values).toContain('openAI'); + expect(values).not.toContain('ollama'); + expect(values).not.toContain('Ollama'); + }); +}); diff --git a/src/components/configuration/sections/endpoints/__tests__/coverage.test.tsx b/src/components/configuration/sections/endpoints/__tests__/coverage.test.tsx new file mode 100644 index 00000000..424d7dde --- /dev/null +++ b/src/components/configuration/sections/endpoints/__tests__/coverage.test.tsx @@ -0,0 +1,161 @@ +import '@/locales/i18n'; +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, screen, within } from '@testing-library/react'; +import type { HiddenFields } from '@/test/coverage'; +import type * as t from '@/types'; +import { + COVERAGE_TEST_TIMEOUT_MS, + UNKNOWN_KEY, + SYNTHETIC_TEXT, + SYNTHETIC_GROUP, + typeInto, + sampleValue, + staleHidden, + objectFields, + editSynthetic, + missingLeaves, + renderedLeaves, + objectDataPath, + findSchemaField, + unknownSettingsAt, + withSyntheticFields, +} from '@/test/coverage'; +import { toFieldId } from '@/components/configuration/renderer'; +import { renderTab, PAGE_TREE, RAW_TREE } from '@/test/page'; +import { setValueAtPath } from '@/utils'; + +vi.setConfig({ testTimeout: COVERAGE_TEST_TIMEOUT_MS }); + +vi.mock('@tanstack/react-router', async (importOriginal) => + (await import('@/test/router')).routerless(importOriginal), +); + +const CUSTOM = findSchemaField(RAW_TREE, 'endpoints.custom')!; +const NAME = 'Sample'; +const ENTRY: t.ConfigRecord = { + ...((sampleValue(CUSTOM) as t.ConfigValue[])[0] as t.ConfigRecord), + name: NAME, + baseURL: 'https://api.example.com/v1', +}; +const DATA: t.ConfigRecord = { endpoints: { custom: [ENTRY] } }; +const ENTRY_PATH = 'endpoints.custom.0'; + +const HIDDEN: HiddenFields = {}; + +const DEFINITION = 'endpoints.custom.[].customParams.paramDefinitions.[]'; +/** + * Leaves another leaf's row edits: a definition's label and description row + * says when the text is a built-in locale code (`labelCode`) and switches it + * to custom text. + */ +const EDITED_WITH: Readonly> = { + [`${DEFINITION}.labelCode`]: `${DEFINITION}.label`, + [`${DEFINITION}.descriptionCode`]: `${DEFINITION}.description`, +}; + +function renderCustom(options: Omit[0], 'tab'>) { + return renderTab({ tab: 'custom', expandAll: true, ...options }); +} + +/** Opens the editor drawer of an endpoint from its row in the list. */ +function openEndpoint(name: string): HTMLElement { + const button = document.querySelector( + `[data-endpoint-row="${name}"] button[aria-haspopup="dialog"]`, + ); + if (!button) throw new Error(`no row for ${name}`); + fireEvent.click(button); + return screen.getByRole('dialog'); +} + +/** Picks each of `keys` that an "Add setting" list of the open editor offers. */ +function addSettings(dialog: HTMLElement, keys: string[]): void { + for (const key of keys) { + const picker = [...dialog.querySelectorAll('select[id$="-add"]')].find( + (select) => [...select.options].some((option) => option.value === key), + ); + if (picker) fireEvent.change(picker, { target: { value: key } }); + } +} + +/** The synthetic keys of an object, relative to the endpoint entry (`models.zzNewSetting`). */ +function syntheticKeys(base: string): string[] { + const prefix = base === ENTRY_PATH ? '' : `${base.slice(ENTRY_PATH.length + 1)}.`; + return [`${prefix}${SYNTHETIC_TEXT}`, `${prefix}${SYNTHETIC_GROUP}`]; +} + +describe('Custom endpoints tab: every schema field is reachable', () => { + it('renders every leaf of an endpoint in its editor', () => { + renderCustom({ yaml: DATA }); + openEndpoint(NAME); + const shown = new Set(renderedLeaves(CUSTOM, DATA)); + for (const [leaf, row] of Object.entries(EDITED_WITH)) if (shown.has(row)) shown.add(leaf); + expect(missingLeaves(CUSTOM, shown, HIDDEN)).toEqual([]); + }); + + it('names no hidden field the schema no longer has', () => { + expect(staleHidden([CUSTOM], HIDDEN)).toEqual([]); + }); +}); + +const OBJECTS = objectFields(CUSTOM); + +describe('Custom endpoints tab: settings a newer schema adds', () => { + it.each(OBJECTS.map((field) => [field.path, field] as const))( + 'under %s: get a control, and an edit is saved at their path', + (_path, field) => { + const view = renderCustom({ tree: withSyntheticFields(PAGE_TREE, field.path), yaml: DATA }); + const dialog = openEndpoint(NAME); + const base = objectDataPath(field, DATA); + addSettings(dialog, syntheticKeys(base)); + expect(editSynthetic(base, view.plan)).toEqual([]); + }, + ); + + it('in Create endpoint: the new endpoint opens in its editor, which edits them', async () => { + vi.useFakeTimers(); + try { + const view = renderCustom({ tree: withSyntheticFields(PAGE_TREE, 'endpoints.custom') }); + fireEvent.click(screen.getByRole('button', { name: 'Create endpoint' })); + fireEvent.click(screen.getByRole('button', { name: /Blank endpoint/ })); + const draft = 'endpoints.custom.9999'; + typeInto(`${draft}.name`, 'Fresh'); + typeInto(`${draft}.baseURL`, 'https://api.example.com/v1'); + typeInto(`${draft}.apiKey`, '${FRESH_KEY}'); + const models = document.querySelector( + `#${toFieldId(`${draft}.models.default`)} input:not([type="hidden"])`, + ); + if (!models) throw new Error('no model input'); + fireEvent.change(models, { target: { value: 'fresh-model' } }); + fireEvent.keyDown(models, { key: 'Enter' }); + await act(async () => { + vi.runOnlyPendingTimers(); + }); + const create = within(screen.getByRole('dialog')).getByRole('button', { + name: 'Create endpoint', + }); + fireEvent.click(create); + await act(async () => { + vi.runOnlyPendingTimers(); + }); + addSettings(screen.getByRole('dialog'), [SYNTHETIC_TEXT, SYNTHETIC_GROUP]); + expect(editSynthetic(ENTRY_PATH, view.plan)).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('Custom endpoints tab: keys the schema does not know', () => { + it.each(OBJECTS.map((field) => [field.path, field] as const))( + 'under %s: are listed read-only', + (_path, field) => { + const base = objectDataPath(field, DATA); + renderCustom({ yaml: setValueAtPath(DATA, `${base}.${UNKNOWN_KEY}`, 'kept as is') }); + openEndpoint(NAME); + const list = unknownSettingsAt(base); + expect(list).not.toBeNull(); + expect(list).toHaveTextContent(UNKNOWN_KEY); + expect(list).toHaveTextContent('kept as is'); + }, + ); +}); diff --git a/src/components/configuration/sections/endpoints/__tests__/model.test.ts b/src/components/configuration/sections/endpoints/__tests__/model.test.ts index d4624aa8..333eba52 100644 --- a/src/components/configuration/sections/endpoints/__tests__/model.test.ts +++ b/src/components/configuration/sections/endpoints/__tests__/model.test.ts @@ -8,6 +8,7 @@ import { validateEndpoint, hasBlockingIssue, } from '../model'; +import { monogram } from '../List'; const complete: t.ConfigRecord = { name: 'OpenRouter', @@ -77,6 +78,18 @@ describe('validateEndpoint', () => { ]); }); + it('rejects a name LibreChat serves under the same key as another (any case of "ollama")', () => { + const issues = validateEndpoint({ + entry: { ...complete, name: 'ollama' }, + otherNames: ['Ollama'], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues).toEqual([ + { field: 'name', code: 'nameMerged', severity: 'error', params: { name: 'Ollama' } }, + ]); + }); + it('flags blank models and warns about repeated ones (custom-09)', () => { const entry = { ...complete, @@ -153,3 +166,12 @@ describe('list row helpers', () => { expect(isUsableBaseURL('ftp://x')).toBe(false); }); }); + +describe('monogram', () => { + it('takes the initials of words, skipping punctuation', () => { + expect(monogram('Azure (DeepSeek)')).toBe('AD'); + expect(monogram('open_router')).toBe('OR'); + expect(monogram('Mistral')).toBe('MI'); + expect(monogram('(x)')).toBe('X'); + }); +}); diff --git a/src/components/configuration/sections/endpoints/issues.ts b/src/components/configuration/sections/endpoints/issues.ts index 7c84176d..18bb6a13 100644 --- a/src/components/configuration/sections/endpoints/issues.ts +++ b/src/components/configuration/sections/endpoints/issues.ts @@ -5,6 +5,7 @@ const ISSUE_KEYS: Readonly> = { nameWhitespace: 'com_config_ce_issue_name_whitespace', nameTaken: 'com_config_ce_issue_name_taken', nameSimilar: 'com_config_ce_issue_name_similar', + nameMerged: 'com_config_ce_issue_name_merged', nameReserved: 'com_config_ce_issue_name_reserved', apiKeyRequired: 'com_config_ce_issue_api_key_required', baseUrlRequired: 'com_config_ce_issue_base_url_required', diff --git a/src/components/configuration/sections/endpoints/model.ts b/src/components/configuration/sections/endpoints/model.ts index 4c8b4b83..110c5fd3 100644 --- a/src/components/configuration/sections/endpoints/model.ts +++ b/src/components/configuration/sections/endpoints/model.ts @@ -5,11 +5,11 @@ * key, a base URL and a model list (`loadCustomEndpointsConfig`), and its * schema requires at least one default model even when the list is fetched. */ -import { EModelEndpoint } from 'librechat-data-provider'; +import { normalizeEndpointName } from 'librechat-data-provider'; import type * as t from '@/types'; +import { RESERVED_ENDPOINT_NAMES } from '@/constants'; import { isConfigRecord } from '@/utils'; -const RESERVED_NAMES: ReadonlySet = new Set(Object.values(EModelEndpoint)); const USER_PROVIDED = 'user_provided'; const ENV_PLACEHOLDER_RE = /\$\{[A-Za-z0-9_]+\}/; @@ -65,23 +65,23 @@ export function isUsableBaseURL(baseURL: string): boolean { } } -/** Names LibreChat reserves for its built-in endpoints (`openAI`, `anthropic`, …). */ -function isReservedEndpointName(name: string): boolean { - return RESERVED_NAMES.has(name); -} - function nameIssues(name: t.ConfigValue, otherNames: readonly string[]): t.EndpointIssue[] { if (!hasText(name)) { return [{ field: 'name', code: 'nameRequired', severity: 'error', unserved: true }]; } const trimmed = name.trim(); if (trimmed !== name) return [{ field: 'name', code: 'nameWhitespace', severity: 'error' }]; - if (isReservedEndpointName(trimmed)) { + if (RESERVED_ENDPOINT_NAMES.has(trimmed)) { return [{ field: 'name', code: 'nameReserved', severity: 'error' }]; } if (otherNames.includes(name)) { return [{ field: 'name', code: 'nameTaken', severity: 'error', params: { name } }]; } + const served = normalizeEndpointName(trimmed); + const merged = otherNames.find((other) => normalizeEndpointName(other.trim()) === served); + if (merged !== undefined) { + return [{ field: 'name', code: 'nameMerged', severity: 'error', params: { name: merged } }]; + } const lower = trimmed.toLowerCase(); const similar = otherNames.find((other) => other.trim().toLowerCase() === lower); if (similar !== undefined) { diff --git a/src/components/configuration/sections/features/Agent.tsx b/src/components/configuration/sections/features/Agent.tsx index 52c681bc..b792a985 100644 --- a/src/components/configuration/sections/features/Agent.tsx +++ b/src/components/configuration/sections/features/Agent.tsx @@ -1,17 +1,26 @@ -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { ButtonGroup } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { useLocalize, useFieldStates, useValuesScope, usePendingVisibility } from '@/hooks'; import { agentMode, isAgentKeyHidden, planAgentSwitch } from './state'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; import { NestedGroup, toFieldId } from '../../renderer'; +import { UnknownSettings } from '../../UnknownSettings'; import { MEMORY_AGENT_KEYS } from '@/constants'; import { countConfigured } from '../../counts'; import { ConfigRow } from '../../ConfigRow'; import { labelForPath } from '../../meta'; +import { isConfigRecord } from '@/utils'; import { FeatureFields } from './Fields'; import { RefPicker } from './Picker'; const MODES: readonly t.MemoryAgentMode[] = ['id', 'inline']; +const ENABLED_KEY = 'enabled'; +/** The keys this editor places: the switch and each definition's own. */ +const PLACED_KEYS: ReadonlySet = new Set([ + ENABLED_KEY, + ...MODES.flatMap((mode) => MEMORY_AGENT_KEYS[mode]), +]); /** No definition: nothing pressed. An undefined `selected` would leave the group tracking clicks itself. */ const NO_MODE = ''; @@ -43,7 +52,12 @@ export function MemoryAgent({ field, ...props }: t.MemoryAgentProps) { const localize = useLocalize(); const scope = useValuesScope(); const visibility = usePendingVisibility(); + const rowFilter = useContext(RowFilterContext); const path = `${props.parentPath}.${field.key}`; + const agentValue = props.getValue( + path, + isConfigRecord(props.parentValue) ? props.parentValue[field.key] : undefined, + ); const children = field.children ?? []; const [paths] = useState(() => children.map((child) => `${path}.${child.key}`)); const states = useFieldStates(paths); @@ -103,10 +117,12 @@ export function MemoryAgent({ field, ...props }: t.MemoryAgentProps) { const modeFields = children.filter( (child) => mode !== undefined && MEMORY_AGENT_KEYS[mode].includes(child.key), ); - const enabledField = children.filter((child) => child.key === 'enabled'); + const enabledField = children.filter((child) => child.key === ENABLED_KEY); + /** Keys a newer schema adds to the agent: shown whatever the definition, so none is lost. */ + const otherFields = children.filter((child) => !PLACED_KEYS.has(child.key)); const provider = visibleValue.provider; const controls: t.FeatureControls = { - provider: (control) => , + provider: (control) => , model: (control) => ( )} + {otherFields.length > 0 && ( + + )} + {!rowFilter && } ); } diff --git a/src/components/configuration/sections/features/Filters.tsx b/src/components/configuration/sections/features/Filters.tsx index 4c726a66..2de9cbb3 100644 --- a/src/components/configuration/sections/features/Filters.tsx +++ b/src/components/configuration/sections/features/Filters.tsx @@ -1,10 +1,12 @@ import type * as t from '@/types'; -import { getFieldOptions } from '../../controls'; +import { optionLabels, settingRows } from '../../values'; +import { UnknownSettings } from '../../UnknownSettings'; import { labelForPath } from '../../meta'; import { isConfigRecord } from '@/utils'; import { useLocalize } from '@/hooks'; const PII_KEY = 'pii'; +const SURFACE_LISTED_KEYS: ReadonlySet = new Set([PII_KEY]); const PII_LISTED_KEYS: ReadonlySet = new Set([ 'action', 'fields', @@ -16,33 +18,8 @@ function childField(field: t.SchemaField | undefined, key: string): t.SchemaFiel return field?.children?.find((child) => child.key === key); } -/** An enum value (or list of them) as its option labels. */ -function optionLabels( - field: t.SchemaField | undefined, - value: t.ConfigValue, - localize: t.LocalizeFn, -): string[] { - if (value == null) return []; - const values = Array.isArray(value) ? value : [value]; - const labels = new Map( - (field ? getFieldOptions(field, localize) : []).map((option) => [option.value, option.label]), - ); - return values.map((item) => labels.get(String(item)) ?? String(item)); -} - -function scalarText( - field: t.SchemaField | undefined, - value: t.ConfigValue, - localize: t.LocalizeFn, -): string { - if (typeof value === 'boolean') { - return localize(value ? 'com_config_values_on' : 'com_config_values_off'); - } - return optionLabels(field, value, localize).join(', '); -} - /** What one surface's filter does, read from librechat.yaml's value and the schema's labels. */ -export function summarizeSurface( +function summarizeSurface( surface: t.SchemaField, value: t.ConfigValue, localize: t.LocalizeFn, @@ -50,23 +27,18 @@ export function summarizeSurface( const record = isConfigRecord(value) ? value : {}; const piiField = childField(surface, PII_KEY); const pii = isConfigRecord(record[PII_KEY]) ? record[PII_KEY] : undefined; - const extras: t.FilterSurfaceSummary['extras'] = []; - const addExtras = ( - field: t.SchemaField | undefined, - source: t.ConfigRecord, - skip: ReadonlySet, - ) => { - for (const child of field?.children ?? []) { - const childValue = source[child.key]; - if (skip.has(child.key) || childValue == null) continue; - extras.push({ - label: labelForPath(child.path, localize), - value: scalarText(child, childValue, localize), - }); - } - }; - addExtras(surface, record, new Set([PII_KEY])); - if (pii) addExtras(piiField, pii, PII_LISTED_KEYS); + const extras = [ + ...settingRows(surface.children ?? [], record, localize, { + path: surface.path, + skip: SURFACE_LISTED_KEYS, + }), + ...(pii + ? settingRows(piiField?.children ?? [], pii, localize, { + path: `${surface.path}.${PII_KEY}`, + skip: PII_LISTED_KEYS, + }) + : []), + ]; const custom = Array.isArray(pii?.customPatterns) ? pii.customPatterns : []; return { key: surface.key, @@ -138,7 +110,7 @@ function SurfaceCard({ summary }: t.SurfaceCardProps) { )} {summary.extras.map((extra) => ( -
+
{extra.label}
{extra.value}
@@ -155,7 +127,12 @@ function SurfaceCard({ summary }: t.SurfaceCardProps) { * (block or audit), the fields scanned and the patterns used, with the * surfaces nothing filters listed together. */ -export function ContentFiltersRenderer({ fields, getValue, parentPath }: t.FieldRendererProps) { +export function ContentFiltersRenderer({ + fields, + getValue, + parentPath, + parentValue, +}: t.FieldRendererProps) { const localize = useLocalize(); const summaries = fields.map((surface) => summarizeSurface(surface, getValue(`${parentPath}.${surface.key}`, undefined), localize), @@ -191,6 +168,7 @@ export function ContentFiltersRenderer({ fields, getValue, parentPath }: t.Field

{localize('com_config_features_filters_how')}

+
); } diff --git a/src/components/configuration/sections/features/Interface.tsx b/src/components/configuration/sections/features/Interface.tsx index baa20d93..271cf95f 100644 --- a/src/components/configuration/sections/features/Interface.tsx +++ b/src/components/configuration/sections/features/Interface.tsx @@ -1,34 +1,99 @@ -import { useContext } from 'react'; +import { useContext, useState } from 'react'; import type * as t from '@/types'; -import { INTERFACE_GROUPS, INTERFACE_OTHER_GROUP } from '@/constants'; +import { INTERFACE_GROUPS, INTERFACE_OTHER_GROUP, TERMS_ACCEPTANCE_PATH } from '@/constants'; +import { ConfirmChoiceDialog } from '../../fields/ConfirmChoice'; import { RowFilterContext } from '@/contexts/RowFilterContext'; import { UnknownSettings } from '../../UnknownSettings'; +import { useLocalize, useValuesScope } from '@/hooks'; +import { sourceLabel } from '../../values'; +import { hasConfigValue } from '@/utils'; import { FeatureGroup } from './Group'; const GROUPED_KEYS: ReadonlySet = new Set( INTERFACE_GROUPS.flatMap((group) => group.fields), ); +/** What users are asked to accept: a link to the terms, or the dialog's own text. */ +const TERMS_CONTENT = ['termsOfService.externalUrl', 'termsOfService.modalContent']; + /** * Interface settings by purpose (FX-5): chat controls, onboarding and legal * (welcome text, privacy policy, terms of service), retention, sharing, * scheduling, the trace viewer and the MCP menu. Fields no group lists (a * newer schema) follow in "More settings", and keys the schema doesn't know - * are listed read-only at the end (D8). + * are listed read-only at the end (D8). Turning on required acceptance of + * the terms asks first: it also gates this admin's own saves. */ export function InterfaceRenderer(props: t.FieldRendererProps) { + const localize = useLocalize(); + const scope = useValuesScope(); const rowFilter = useContext(RowFilterContext); + const [confirmOpen, setConfirmOpen] = useState(false); const { fields, parentValue, parentPath } = props; + const acceptancePath = TERMS_ACCEPTANCE_PATH; const other: t.FeatureGroupDef = { ...INTERFACE_OTHER_GROUP, fields: fields.filter((field) => !GROUPED_KEYS.has(field.key)).map((field) => field.key), }; + const onChange = (path: string, value: t.ConfigValue) => { + if (path === acceptancePath && value === true && props.getValue(path, undefined) !== true) { + setConfirmOpen(true); + return; + } + props.onChange(path, value); + }; + const hasTermsContent = TERMS_CONTENT.some((key) => + hasConfigValue(props.getValue(`${parentPath}.${key}`, undefined)), + ); + const requireAcceptance = () => { + setConfirmOpen(false); + props.onChange(acceptancePath, true); + }; return ( <> {[...INTERFACE_GROUPS, other].map((group) => ( - + ))} {!rowFilter && } + {confirmOpen && ( + setConfirmOpen(false)} + actions={[ + { + label: localize('com_config_terms_acceptance_confirm'), + onSelect: requireAcceptance, + }, + ]} + > +

{localize('com_config_terms_acceptance_body')}

+

+ {scope + ? localize('com_config_terms_acceptance_admin_scope', { + scope: sourceLabel('scope', localize, scope), + }) + : localize('com_config_terms_acceptance_admin')} +

+ {!hasTermsContent && ( +

+ {localize('com_config_terms_acceptance_empty')} +

+ )} +
+ )} ); } diff --git a/src/components/configuration/sections/features/Summarization.tsx b/src/components/configuration/sections/features/Summarization.tsx index 85bffda0..6ecd1429 100644 --- a/src/components/configuration/sections/features/Summarization.tsx +++ b/src/components/configuration/sections/features/Summarization.tsx @@ -19,7 +19,7 @@ export function SummarizationRenderer(props: t.FieldRendererProps) { typeof provider === 'string' && provider !== '' ? `models:${provider}` : undefined; const controls: t.FeatureControls = { trigger: (control) => , - provider: (control) => , + provider: (control) => , model: (control) => , }; return ( diff --git a/src/components/configuration/sections/features/WebSearch.tsx b/src/components/configuration/sections/features/WebSearch.tsx index d8d2d835..3acbd535 100644 --- a/src/components/configuration/sections/features/WebSearch.tsx +++ b/src/components/configuration/sections/features/WebSearch.tsx @@ -1,12 +1,8 @@ import { useContext, useState } from 'react'; import { Button } from '@clickhouse/click-ui'; import type * as t from '@/types'; -import { - WEB_SEARCH_SERVICES, - WEB_SEARCH_SELECTORS, - WEB_SEARCH_ROLE_KEYS, - WEB_SEARCH_GENERAL_KEYS, -} from '@/constants'; +import { CATEGORIES, selectedOf, otherServices, serviceLabel, selectedServices } from './services'; +import { WEB_SEARCH_SERVICES, WEB_SEARCH_ROLE_KEYS, WEB_SEARCH_GENERAL_KEYS } from '@/constants'; import { RowFilterContext } from '@/contexts/RowFilterContext'; import { UnknownSettings } from '../../UnknownSettings'; import { CredentialControl } from './Credential'; @@ -16,67 +12,6 @@ import { FeatureFields } from './Fields'; import { isRowVisible } from '@/utils'; import { useLocalize } from '@/hooks'; -const CATEGORIES: readonly t.WebSearchCategory[] = ['providers', 'scrapers', 'rerankers']; - -/** Adds a service to a list of views, merging the roles, credentials and options of one already there. */ -function addService( - views: t.WebSearchServiceView[], - service: t.WebSearchService, - role?: t.WebSearchCategory, -): t.WebSearchServiceView[] { - const existing = views.find((view) => view.value === service.value); - if (!existing) { - return [...views, { ...service, roles: role ? [role] : [] }]; - } - const keys = new Set(existing.credentials.map((credential) => credential.key)); - const merged: t.WebSearchServiceView = { - ...existing, - roles: role && !existing.roles.includes(role) ? [...existing.roles, role] : existing.roles, - credentials: [ - ...existing.credentials, - ...service.credentials.filter((credential) => !keys.has(credential.key)), - ], - optionKeys: [...new Set([...existing.optionKeys, ...service.optionKeys])], - }; - return views.map((view) => (view === existing ? merged : view)); -} - -/** - * The services selected for search, scraping and reranking, one view per - * service. A service in use also lists the settings of its roles not - * selected (Tavily's extract URL and scraper options while it only - * searches): "Show other services" leaves it out, so they would be nowhere - * else. - */ -export function selectedServices( - selected: Readonly>>, -): t.WebSearchServiceView[] { - const views = CATEGORIES.reduce((acc, category) => { - const service = WEB_SEARCH_SERVICES[category].find((s) => s.value === selected[category]); - return service ? addService(acc, service, category) : acc; - }, []); - const used = new Set(views.map((view) => view.value)); - return CATEGORIES.reduce( - (acc, category) => - WEB_SEARCH_SERVICES[category] - .filter((service) => used.has(service.value) && service.value !== selected[category]) - .reduce((merged, service) => addService(merged, service), acc), - views, - ); -} - -/** Every other service, for "Show all services". */ -export function otherServices(inUse: readonly t.WebSearchServiceView[]): t.WebSearchServiceView[] { - const used = new Set(inUse.map((view) => view.value)); - return CATEGORIES.reduce( - (views, category) => - WEB_SEARCH_SERVICES[category] - .filter((service) => !used.has(service.value)) - .reduce((acc, service) => addService(acc, service), views), - [], - ); -} - /** One service: its credentials with their status, then its options. */ function ServiceGroup({ service, ...props }: t.WebSearchServiceProps) { const localize = useLocalize(); @@ -105,12 +40,7 @@ function ServiceGroup({ service, ...props }: t.WebSearchServiceProps) { counts.configured === 0 && !paths.some((path) => hasPendingValue(path, props.editedValues)); if (shown.length === 0 || hiddenByFilter || hiddenByConfigured) return null; - const label = inUse - ? localize('com_config_features_ws_service_roles', { - name, - roles: service.roles.map((role) => localize(WEB_SEARCH_ROLE_KEYS[role])).join(', '), - }) - : name; + const label = serviceLabel(service, localize); return ( > = {}; - for (const category of CATEGORIES) { - const value = getValue(`${parentPath}.${WEB_SEARCH_SELECTORS[category]}`, undefined); - if (typeof value === 'string') selected[category] = value; - } + const selected = selectedOf((path) => getValue(path, undefined), parentPath); const inUse = selectedServices(selected); const others = otherServices(inUse); const automatic = CATEGORIES.filter((category) => selected[category] === undefined); diff --git a/src/components/configuration/sections/features/__tests__/Interface.test.tsx b/src/components/configuration/sections/features/__tests__/Interface.test.tsx index fbdf6409..c8b53c33 100644 --- a/src/components/configuration/sections/features/__tests__/Interface.test.tsx +++ b/src/components/configuration/sections/features/__tests__/Interface.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { screen } from '@testing-library/react'; +import { screen, within, fireEvent } from '@testing-library/react'; import { sectionFields, renderSection } from './harness'; import { InterfaceRenderer } from '../Interface'; import { INTERFACE_GROUPS } from '@/constants'; @@ -79,4 +79,16 @@ describe('InterfaceRenderer', () => { const trace = screen.getByRole('heading', { name: /^Trace viewer/ }); expect(trace).toHaveTextContent(/1 of 7 set/); }); + + it('asks before requiring acceptance of the terms, since it also gates the admin', () => { + const { onChange } = renderSection(InterfaceRenderer, { section: 'interface' }); + const control = document.getElementById('interface-termsOfService-modalAcceptance'); + if (!control) throw new Error('no Require acceptance control'); + fireEvent.click(within(control).getByRole('button', { name: 'On' })); + const dialog = screen.getByRole('dialog', { name: 'Require everyone to accept the terms?' }); + expect(dialog).toHaveTextContent(/This includes you and other admins/); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Require acceptance' })); + expect(onChange).toHaveBeenCalledWith('interface.termsOfService.modalAcceptance', true); + }); }); diff --git a/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx b/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx index f7e3a4c6..3fd42c4a 100644 --- a/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx +++ b/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { fireEvent, screen } from '@testing-library/react'; -import { WebSearchRenderer, otherServices, selectedServices } from '../WebSearch'; +import { otherServices, selectedServices, webSearchTocItems } from '../services'; +import { WebSearchRenderer } from '../WebSearch'; import { renderSection } from './harness'; import '@/locales/i18n'; @@ -138,3 +139,19 @@ describe('WebSearchRenderer', () => { expect(screen.queryByRole('heading', { name: /^Cohere/ })).not.toBeInTheDocument(); }); }); + +describe('webSearchTocItems', () => { + it('lists the services the section shows, not the ones behind "Show all services"', () => { + const items = webSearchTocItems( + { + webSearch: { searchProvider: 'tavily', scraperProvider: 'tavily', rerankerType: 'cohere' }, + }, + 'webSearch', + (key) => key, + ); + expect(items.map((item) => item.id)).toEqual([ + 'section-webSearch-service-tavily', + 'section-webSearch-service-cohere', + ]); + }); +}); diff --git a/src/components/configuration/sections/features/__tests__/coverage.test.tsx b/src/components/configuration/sections/features/__tests__/coverage.test.tsx new file mode 100644 index 00000000..fbffaec4 --- /dev/null +++ b/src/components/configuration/sections/features/__tests__/coverage.test.tsx @@ -0,0 +1,222 @@ +import '@/locales/i18n'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, within } from '@testing-library/react'; +import type { CoverageFixture, HiddenFields } from '@/test/coverage'; +import type * as t from '@/types'; +import { + COVERAGE_TEST_TIMEOUT_MS, + turnOn, + typeInto, + planWrites, + staleHidden, + missingLeaves, + schemaLeaves, + SYNTHETIC_FLAG, + SYNTHETIC_TEXT, + renderedAcross, + SYNTHETIC_GROUP, + UNKNOWN_KEY, + findSchemaField, + localizeEn, + sampleValue, + unknownSettingsAt, + withSyntheticFields, +} from '@/test/coverage'; +import { setValueAtPath, isInterfacePermissionPath } from '@/utils'; +import { renderTab, PAGE_TREE, RAW_TREE } from '@/test/page'; +import { labelForPath } from '@/components/configuration'; + +vi.setConfig({ testTimeout: COVERAGE_TEST_TIMEOUT_MS }); + +vi.mock('@tanstack/react-router', async (importOriginal) => + (await import('@/test/router')).routerless(importOriginal), +); + +/** The Features tab without Langfuse, whose connection the page edits through its own API. */ +const TREE = PAGE_TREE.filter((section) => section.key !== 'langfuse'); +const SECTIONS = [ + 'interface', + 'webSearch', + 'memory', + 'speech', + 'ocr', + 'summarization', + 'actions', + 'filteredTools', + 'includedTools', + 'messageFilter', +]; + +const FIXTURES: CoverageFixture[] = [ + { + name: 'inline memory agent, token trigger, selected services', + yaml: { + interface: { + skills: { defaultActiveOnShare: true }, + sharedLinks: { snapshotFiles: true }, + schedules: { maxPerUser: 5 }, + }, + webSearch: { searchProvider: 'searxng', scraperProvider: 'firecrawl', rerankerType: 'jina' }, + memory: { agent: { provider: 'openAI', model: 'gpt-4o' } }, + speech: { + speechTab: { + speechToText: { engineSTT: 'openai' }, + textToSpeech: { engineTTS: 'openai' }, + }, + }, + messageFilter: { pii: { customPatterns: [{ id: 'ssn', label: 'SSN', regex: '\\d+' }] } }, + }, + overrides: { summarization: { trigger: { type: 'token_ratio', value: 0.8 } } }, + open: () => fireEvent.click(screen.getByText(/Show other services/)), + }, + { + name: 'memory agent by id', + yaml: { memory: { agent: { id: 'agent_123' } } }, + }, +]; + +/** Role permissions: the server leaves them out of the editor; roles set them on the Access page. */ +const PERMISSIONS: HiddenFields = Object.fromEntries( + schemaLeaves(findSchemaField(RAW_TREE, 'interface')!) + .filter((path) => isInterfacePermissionPath(path)) + .map((path) => [path, 'role permission, set per role on the Access page']), +); + +const HIDDEN: HiddenFields = { ...PERMISSIONS }; + +function roots(tree: t.SchemaField[]): t.SchemaField[] { + return SECTIONS.map((key) => { + const root = findSchemaField(tree, key); + if (!root) throw new Error(`no ${key} in the schema`); + return root; + }); +} + +describe('Features tab: every schema field is reachable', () => { + it('renders every leaf of its sections, or names why not', () => { + const shown = renderedAcross(roots(RAW_TREE), FIXTURES, (fixture) => + renderTab({ tab: 'features', tree: TREE, expandAll: true, ...fixture }), + ); + const missing = roots(RAW_TREE).flatMap((root) => missingLeaves(root, shown, HIDDEN)); + expect(missing).toEqual([]); + }); + + it('names no hidden field the schema no longer has', () => { + expect(staleHidden(roots(RAW_TREE), HIDDEN)).toEqual([]); + }); +}); + +/** Where a newer schema could add settings: each renderer, and the objects and variants inside. */ +const NEW_FIELD_PARENTS: Array<[string, string, t.ConfigRecord]> = [ + ['interface', 'interface', {}], + ['interface.traceViewer', 'interface.traceViewer', {}], + ['webSearch', 'webSearch', {}], + [ + 'webSearch.firecrawlOptions', + 'webSearch.firecrawlOptions', + { webSearch: { scraperProvider: 'firecrawl' } }, + ], + ['memory', 'memory', {}], + ['memory.agent', 'memory.agent', { memory: { agent: { provider: 'openAI', model: 'gpt-4o' } } }], + ['summarization', 'summarization', {}], + ['summarization.contextPruning', 'summarization.contextPruning', {}], + ['speech', 'speech', {}], + ['speech.tts.elevenlabs', 'speech.tts.elevenlabs', {}], + ['ocr', 'ocr', {}], + ['actions', 'actions', {}], + ['messageFilter', 'messageFilter', {}], +]; + +describe('Features tab: settings a newer schema adds', () => { + it.each(NEW_FIELD_PARENTS)( + 'under %s: get a control, and an edit is saved at their path', + (parent, dataPath, yaml) => { + const view = renderTab({ + tab: 'features', + tree: withSyntheticFields(TREE, parent), + yaml, + expandAll: true, + }); + const text = `${dataPath}.${SYNTHETIC_TEXT}`; + const flag = `${dataPath}.${SYNTHETIC_GROUP}.${SYNTHETIC_FLAG}`; + typeInto(text, 'from a newer LibreChat'); + turnOn(flag); + const plan = view.plan(); + expect(planWrites(plan, text, 'from a newer LibreChat')).toBe(true); + expect(planWrites(plan, flag, true)).toBe(true); + }, + ); +}); + +describe('Features tab: keys the schema does not know', () => { + it.each(NEW_FIELD_PARENTS)('under %s: are listed read-only', (_parent, dataPath, yaml) => { + renderTab({ + tab: 'features', + tree: TREE, + yaml: setValueAtPath(yaml, `${dataPath}.${UNKNOWN_KEY}`, 'kept as is'), + expandAll: true, + }); + const list = unknownSettingsAt(dataPath); + expect(list).not.toBeNull(); + expect(list).toHaveTextContent(UNKNOWN_KEY); + expect(list).toHaveTextContent('kept as is'); + }); +}); + +/** A content filter surface's card in the read-only summary, by its title. */ +function surfaceCard(title: string): HTMLElement { + const card = within(screen.getByRole('list', { name: 'Filtered content' })) + .getAllByRole('listitem') + .find((item) => item.firstElementChild?.textContent?.startsWith(title)); + if (!card) throw new Error(`no summary card for ${title}`); + return card; +} + +/** Keys of `pii` the summary states in rows of its own (the action badge, Scans and Patterns). */ +const PII_ROWS = /\.pii\.(action|fields|starterPatterns|customPatterns)(\.|$)/; + +describe('Content filters (read-only summary of librechat.yaml)', () => { + const filters = findSchemaField(RAW_TREE, 'filters')!; + + it('lists every setting librechat.yaml sets, on the card of its surface', () => { + renderTab({ tab: 'features', tree: TREE, yaml: { filters: sampleValue(filters) } }); + const missing = (filters.children ?? []).flatMap((surface) => { + const card = surfaceCard(labelForPath(surface.path, localizeEn)); + return schemaLeaves(surface).filter((leaf) => { + if (PII_ROWS.test(leaf)) return within(card).queryByText('Block') == null; + return !card.textContent?.includes(labelForPath(leaf, localizeEn)); + }); + }); + expect(missing).toEqual([]); + }); + + it.each(['filters.messages', 'filters.messages.pii'])( + 'shows what a newer schema adds under %s', + (parent) => { + const added = { [SYNTHETIC_TEXT]: 'redact', [SYNTHETIC_GROUP]: { [SYNTHETIC_FLAG]: true } }; + const yaml = setValueAtPath({ filters: { messages: { pii: { action: 'block' } } } }, parent, { + ...(parent.endsWith('.pii') && { action: 'block' }), + ...added, + }); + renderTab({ tab: 'features', tree: withSyntheticFields(TREE, parent), yaml }); + const card = surfaceCard('Messages'); + expect(card).toHaveTextContent(`${SYNTHETIC_TEXT}redact`); + expect(card).toHaveTextContent(`${SYNTHETIC_GROUP} › ${SYNTHETIC_FLAG}On`); + }, + ); + + it('lists keys the schema does not know, read-only', () => { + renderTab({ + tab: 'features', + tree: TREE, + yaml: { + filters: { + [UNKNOWN_KEY]: 'kept', + messages: { pii: { action: 'block' }, [UNKNOWN_KEY]: 'on the surface' }, + }, + }, + }); + expect(unknownSettingsAt('filters')).toHaveTextContent(UNKNOWN_KEY); + expect(surfaceCard('Messages')).toHaveTextContent(`${UNKNOWN_KEY}on the surface`); + }); +}); diff --git a/src/components/configuration/sections/features/index.ts b/src/components/configuration/sections/features/index.ts index 70265f02..a9f9d74f 100644 --- a/src/components/configuration/sections/features/index.ts +++ b/src/components/configuration/sections/features/index.ts @@ -3,3 +3,4 @@ export { InterfaceRenderer } from './Interface'; export { MemoryRenderer } from './Memory'; export { WebSearchRenderer } from './WebSearch'; export { SummarizationRenderer } from './Summarization'; +export { webSearchTocItems } from './services'; diff --git a/src/components/configuration/sections/features/services.ts b/src/components/configuration/sections/features/services.ts new file mode 100644 index 00000000..594a1409 --- /dev/null +++ b/src/components/configuration/sections/features/services.ts @@ -0,0 +1,111 @@ +import type * as t from '@/types'; +import { WEB_SEARCH_SERVICES, WEB_SEARCH_SELECTORS, WEB_SEARCH_ROLE_KEYS } from '@/constants'; +import { getValueAtPath } from '@/utils'; + +export const CATEGORIES: readonly t.WebSearchCategory[] = ['providers', 'scrapers', 'rerankers']; + +/** The service chosen for search, scraping and reranking, read through `valueAt` (a data path → its value). */ +export function selectedOf( + valueAt: (path: string) => t.ConfigValue, + dataKey: string, +): Partial> { + const selected: Partial> = {}; + for (const category of CATEGORIES) { + const value = valueAt(`${dataKey}.${WEB_SEARCH_SELECTORS[category]}`); + if (typeof value === 'string') selected[category] = value; + } + return selected; +} + +/** Adds a service to a list of views, merging the roles, credentials and options of one already there. */ +function addService( + views: t.WebSearchServiceView[], + service: t.WebSearchService, + role?: t.WebSearchCategory, +): t.WebSearchServiceView[] { + const existing = views.find((view) => view.value === service.value); + if (!existing) { + return [...views, { ...service, roles: role ? [role] : [] }]; + } + const keys = new Set(existing.credentials.map((credential) => credential.key)); + const merged: t.WebSearchServiceView = { + ...existing, + roles: role && !existing.roles.includes(role) ? [...existing.roles, role] : existing.roles, + credentials: [ + ...existing.credentials, + ...service.credentials.filter((credential) => !keys.has(credential.key)), + ], + optionKeys: [...new Set([...existing.optionKeys, ...service.optionKeys])], + }; + return views.map((view) => (view === existing ? merged : view)); +} + +/** + * The services selected for search, scraping and reranking, one view per + * service. A service in use also lists the settings of its roles not + * selected (Tavily's extract URL and scraper options while it only + * searches): "Show other services" leaves it out, so they would be nowhere + * else. + */ +export function selectedServices( + selected: Readonly>>, +): t.WebSearchServiceView[] { + const views = CATEGORIES.reduce((acc, category) => { + const service = WEB_SEARCH_SERVICES[category].find((s) => s.value === selected[category]); + return service ? addService(acc, service, category) : acc; + }, []); + const used = new Set(views.map((view) => view.value)); + return CATEGORIES.reduce( + (acc, category) => + WEB_SEARCH_SERVICES[category] + .filter((service) => used.has(service.value) && service.value !== selected[category]) + .reduce((merged, service) => addService(merged, service), acc), + views, + ); +} + +/** Every other service, for "Show all services". */ +export function otherServices(inUse: readonly t.WebSearchServiceView[]): t.WebSearchServiceView[] { + const used = new Set(inUse.map((view) => view.value)); + return CATEGORIES.reduce( + (views, category) => + WEB_SEARCH_SERVICES[category] + .filter((service) => !used.has(service.value)) + .reduce((acc, service) => addService(acc, service), views), + [], + ); +} + +/** A service's group title: its name, with what it is used for when selected ("Tavily · search, page scraping"). */ +export function serviceLabel(service: t.WebSearchServiceView, localize: t.LocalizeFn): string { + const name = localize(service.labelKey); + if (service.roles.length === 0) return name; + return localize('com_config_features_ws_service_roles', { + name, + roles: service.roles.map((role) => localize(WEB_SEARCH_ROLE_KEYS[role])).join(', '), + }); +} + +/** + * Contents entries of the Web search section: the services the page shows + * (the ones selected for search, scraping and reranking), not the ones kept + * behind "Show all services". + */ +export function webSearchTocItems( + values: t.ConfigValue, + dataKey: string, + localize: t.LocalizeFn, +): t.TocItem[] { + const selected = selectedOf((path) => getValueAtPath(values, path), dataKey); + return selectedServices(selected).flatMap((service) => { + const first = service.credentials[0]?.key ?? service.optionKeys[0]; + if (!first) return []; + return [ + { + id: `section-${dataKey}-service-${service.value}`, + label: serviceLabel(service, localize), + dataPath: `${dataKey}.${first}`, + }, + ]; + }); +} diff --git a/src/components/configuration/sections/features/state.ts b/src/components/configuration/sections/features/state.ts index d48a3d1e..cab5f5b0 100644 --- a/src/components/configuration/sections/features/state.ts +++ b/src/components/configuration/sections/features/state.ts @@ -1,6 +1,6 @@ import type * as t from '@/types'; import { MEMORY_AGENT_KEYS, TRIGGER_RULES } from '@/constants'; -import { lowerLayerValue } from '../../renderer/defaults'; +import { layerValueBeneath } from '../../renderer/defaults'; import { isEnvReference } from '@/utils'; const ENV_NAME = /^\$\{([A-Za-z0-9_]+)\}$/; @@ -41,9 +41,7 @@ export function credentialState({ /** Whether the layer beneath the edit target (librechat.yaml in Base, Base in a profile) sets a field. */ function lowerSets(state: t.FieldState): boolean { - if (!state.isOverride) return lowerLayerValue(state) !== undefined; - const target = state.revertTarget; - return target?.value !== undefined && (target.source === 'yaml' || target.source === 'inherited'); + return layerValueBeneath(state) !== undefined; } /** Whether a memory agent key is hidden (stored tombstone) or queued to be hidden. */ diff --git a/src/components/configuration/sections/files/EnvVars.tsx b/src/components/configuration/sections/files/EnvVars.tsx index 8ec6d73f..c5ca9869 100644 --- a/src/components/configuration/sections/files/EnvVars.tsx +++ b/src/components/configuration/sections/files/EnvVars.tsx @@ -20,7 +20,7 @@ export function EnvVars({ backend, id }: { backend: t.StorageBackend; id?: strin
    {info.envVars.map((name) => (
  • - + {name}
  • diff --git a/src/components/configuration/sections/files/StorageRenderer.tsx b/src/components/configuration/sections/files/StorageRenderer.tsx index 74d3bcb6..becee775 100644 --- a/src/components/configuration/sections/files/StorageRenderer.tsx +++ b/src/components/configuration/sections/files/StorageRenderer.tsx @@ -1,12 +1,10 @@ -import { useContext, useState } from 'react'; +import { useState } from 'react'; import type * as t from '@/types'; import { backendName, backendsInUse, isCloudFrontReady, resolveStorage } from './storage'; import { STORAGE_FILE_TYPES } from '@/constants'; import { useLocalize, useYamlConfig, useStorageStates } from '@/hooks'; -import { RowFilterContext } from '@/contexts/RowFilterContext'; -import { FieldRenderer, NestedGroup } from '../../renderer'; +import { NestedGroup, OtherSettings } from '../../renderer'; import { getValueAtPath, isConfigRecord } from '@/utils'; -import { UnknownSettings } from '../../UnknownSettings'; import { DecorativeIcon } from '@/components/shared'; import { ConfigSection } from '../../ConfigSection'; import { StorageConfirm } from './StorageConfirm'; @@ -47,7 +45,6 @@ export function StorageRenderer({ }: t.SectionGroupRendererProps) { const localize = useLocalize(); const yaml = useYamlConfig(); - const rowFilter = useContext(RowFilterContext); const states = useStorageStates(); const [pending, setPending] = useState(null); const byId = new Map(sections.map((section) => [section.id, section])); @@ -105,9 +102,6 @@ export function StorageRenderer({ ? countConfigured(perType.fields, PER_TYPE, propsFor(perType).configuredPaths) : undefined; const typesOverridingPrimary = resolved.default !== resolved.primary; - const perTypeProps = perType ? propsFor(perType) : undefined; - /** File types a newer schema adds: generic rows after the purpose-built ones. */ - const otherTypes = perType?.fields.filter((field) => !FILE_TYPES.has(field.key)) ?? []; return ( )} - {perType && perTypeProps && ( + {perType && ( field.key === type)} - fieldProps={perTypeProps} + fieldProps={propsFor(perType)} onRequestChange={requestChange} /> ))} - {otherTypes.length > 0 && } - {!rowFilter && ( - - )} + )} {cloudfront && diff --git a/src/components/configuration/sections/files/__tests__/StorageRenderer.test.tsx b/src/components/configuration/sections/files/__tests__/StorageRenderer.test.tsx index d53d9028..1514f552 100644 --- a/src/components/configuration/sections/files/__tests__/StorageRenderer.test.tsx +++ b/src/components/configuration/sections/files/__tests__/StorageRenderer.test.tsx @@ -149,6 +149,6 @@ describe('StorageRenderer', () => { name: 'Amazon CloudFront (S3)', }), ).not.toBeDisabled(); - expect(screen.getByText(/Values saved in the admin panel are never applied/)).toBeVisible(); + expect(screen.getByText(/values saved in the admin panel are never applied/i)).toBeVisible(); }); }); diff --git a/src/components/configuration/sections/files/storage.ts b/src/components/configuration/sections/files/storage.ts index 90651ea2..7386062f 100644 --- a/src/components/configuration/sections/files/storage.ts +++ b/src/components/configuration/sections/files/storage.ts @@ -41,10 +41,7 @@ export function backendChoices( } /** The backend a field uses: its own value, else the one beneath it, else `fallback`. */ -export function effectiveBackend( - state: t.FieldState, - fallback: t.StorageBackend, -): t.StorageBackend { +function effectiveBackend(state: t.FieldState, fallback: t.StorageBackend): t.StorageBackend { const own = ownValue(state); if (isStorageBackend(own)) return own; const lower = lowerValue(state); diff --git a/src/components/configuration/sections/index.ts b/src/components/configuration/sections/index.ts index 975e6c99..eabc353b 100644 --- a/src/components/configuration/sections/index.ts +++ b/src/components/configuration/sections/index.ts @@ -18,11 +18,6 @@ import { SummarizationRenderer, ContentFiltersRenderer, } from './features'; -import { CustomEndpointsRenderer } from './endpoints'; -import { McpServersRenderer } from './mcp'; -import { LangfuseRenderer } from './LangfuseRenderer'; -import { ProvidersRenderer } from './providers'; -import { ModelSpecsRenderer } from './specs'; import { CreditsRenderer, RateLimitsRenderer, @@ -30,6 +25,11 @@ import { RegistrationRenderer, } from './system'; import { StorageRenderer, FileConfigRenderer } from './files'; +import { CustomEndpointsRenderer } from './endpoints'; +import { LangfuseRenderer } from './LangfuseRenderer'; +import { ProvidersRenderer } from './providers'; +import { ModelSpecsRenderer } from './specs'; +import { McpServersRenderer } from './mcp'; export const SECTION_RENDERERS: Partial>> = { diff --git a/src/components/configuration/sections/mcp/Card.tsx b/src/components/configuration/sections/mcp/Card.tsx index b4fb561f..8eee27c1 100644 --- a/src/components/configuration/sections/mcp/Card.tsx +++ b/src/components/configuration/sections/mcp/Card.tsx @@ -2,10 +2,21 @@ import { useEffect, useRef, useState } from 'react'; import type * as t from '@/types'; import { DecorativeIcon, KebabMenu, StateChip } from '@/components/shared'; import { useExpandableState, useLocalize, useValuesScope } from '@/hooks'; +import { RegionNameContext } from '@/contexts/RegionNameContext'; import { transportLabelKey, transportOf } from './transport'; import { sourceLabel } from '../../values'; import { cn } from '@/utils'; +/** Row-menu icons, the same as the custom endpoint rows'. */ +const ACTION_ICONS: Readonly> = { + override: 'pencil', + rename: 'pencil', + duplicate: 'copy', + revert: 'history', + hide: 'eye-closed', + delete: 'trash', +}; + function toDomId(value: string): string { return value.replace(/[^\w-]+/g, '-'); } @@ -34,7 +45,7 @@ function SourceChips({ badges }: { badges: t.McpServerBadges }) { case 'inherited': return { kind: 'inherited', - label: localize('com_config_ui_state_inherited'), + label: localize('com_config_values_source_inherited'), description: localize('com_config_mcp_source_inherited_desc'), }; case 'scope': @@ -204,6 +215,7 @@ export function McpServerCard({ const menuItems: t.KebabMenuItem[] = actions.map((action) => ({ label: action.label, + icon: ACTION_ICONS[action.id], danger: action.danger, disabled: action.disabled, title: action.reason, @@ -220,7 +232,7 @@ export function McpServerCard({ >
    @@ -238,7 +250,7 @@ export function McpServerCard({ userToggled.current = true; setIsExpanded((prev) => !prev); }} - className="flex min-w-0 flex-1 cursor-pointer items-start gap-3 rounded-lg border-none bg-transparent px-3 py-2.5 text-left transition-colors select-none hover:bg-(--cui-color-background-hover) focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-(--cui-color-outline)" + className="flex min-w-0 flex-1 cursor-pointer items-start gap-3 rounded-lg border-none bg-transparent px-3 py-2.5 text-left select-none focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-(--cui-color-outline)" >