diff --git a/e2e/features.spec.ts b/e2e/features.spec.ts new file mode 100644 index 00000000..2e866910 --- /dev/null +++ b/e2e/features.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { LIVE, LIVE_SKIP_REASON, openConfigTab } from './live'; + +/** + * Features tab journeys (WS13): the memory agent switch, allowlists, web + * search credentials, the summarization trigger and the content filters + * summary. They need a live LibreChat backend (E2E_LIVE=1, E2E_BASE_URL) and + * clean up what they save. The memory journey runs only when librechat.yaml + * defines the memory agent by ID. + */ + +const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' }); +const section = (page: Page, id: string) => page.locator(`#section-${id}`); + +async function review(page: Page): Promise { + await unsavedBar(page) + .getByRole('button', { name: /^Review & save$/ }) + .click(); + return page.getByRole('dialog'); +} + +async function saveAndWait(page: Page) { + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /^Save$/ }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByText('Changes saved', { exact: true }).last()).toBeVisible(); +} + +async function setText(field: Locator, value: string) { + await field.fill(value); + await field.press('Tab'); +} + +test.describe('Features editors', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); + test.describe.configure({ mode: 'serial', timeout: 120_000 }); + + test('the memory agent switches from a librechat.yaml ID to an inline model and back', async ({ + page, + }) => { + await openConfigTab(page, 'features'); + const memory = section(page, 'memory'); + const existing = memory.getByRole('button', { name: 'Existing agent' }); + test.skip( + (await existing.getAttribute('aria-pressed')) !== 'true', + 'librechat.yaml does not define the memory agent by ID', + ); + await memory.getByRole('button', { name: 'Provider and model' }).click(); + await expect(memory.getByText(/Saving hides Agent ID from librechat\.yaml/)).toBeVisible(); + await setText(memory.getByLabel('Provider', { exact: true }), 'openAI'); + await setText(memory.getByLabel('Model', { exact: true }), 'gpt-4o-mini'); + const dialog = await review(page); + await expect(dialog).toContainText('Agent › Agent ID'); + await expect(dialog).toContainText('Hidden'); + await saveAndWait(page); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(memory.getByRole('button', { name: 'Provider and model' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await expect(memory.getByLabel('Model', { exact: true })).toHaveValue('gpt-4o-mini'); + + await memory + .getByRole('button', { name: /^Revert Agent to its librechat\.yaml value/ }) + .click(); + await review(page); + await saveAndWait(page); + await expect(memory.getByRole('button', { name: 'Existing agent' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + test('an Actions allowlist entry is validated and never blank', async ({ page }) => { + await openConfigTab(page, 'features'); + const actions = section(page, 'actions'); + const input = actions.getByLabel('Allowed domains: add a domain'); + await input.press('Enter'); + await expect(unsavedBar(page)).toHaveCount(0); + await input.fill('not a/domain'); + await input.press('Enter'); + await expect(actions.getByRole('alert')).toContainText('is not a domain'); + await input.fill('api.example.com'); + await input.press('Enter'); + await expect(actions.getByText(/^Only these domains are allowed \(1\)/)).toBeVisible(); + await review(page); + await saveAndWait(page); + + await actions.getByRole('button', { name: 'Remove api.example.com' }).click(); + await expect(actions.getByRole('alertdialog')).toContainText('Remove the last domain?'); + await actions.getByRole('button', { name: 'Remove restriction' }).click(); + await expect(actions.getByText(/^No restriction: agent actions can call any/)).toBeVisible(); + await review(page); + await saveAndWait(page); + }); + + test('a web search key is masked, reviewed as hidden and stored encrypted', async ({ page }) => { + await openConfigTab(page, 'features'); + const web = section(page, 'webSearch'); + await web.getByLabel('Search provider', { exact: true }).selectOption('serper'); + const key = web.getByLabel('Serper API key', { exact: true }); + await key.fill('serper-e2e-secret-1234567890'); + await expect(key).toHaveAttribute('type', 'password'); + await key.press('Tab'); + await expect(web.getByText('Will be stored encrypted when you save.')).toBeVisible(); + const dialog = await review(page); + await expect(dialog).not.toContainText('serper-e2e-secret'); + await saveAndWait(page); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(web.getByLabel('Serper API key is configured; value hidden')).toHaveValue( + '...7890', + ); + await expect( + web.getByText('Stored encrypted. Only the last characters are shown.'), + ).toBeVisible(); + + await web.getByRole('button', { name: /^Revert Serper API key/ }).click(); + await web.getByRole('button', { name: /^Revert Search provider/ }).click(); + await review(page); + await saveAndWait(page); + }); + + test('the summarization trigger saves a typed trigger and a valid number', async ({ page }) => { + await openConfigTab(page, 'features'); + const summarization = section(page, 'summarization'); + await summarization.getByLabel('Trigger: when to summarize').selectOption('token_ratio'); + await expect(summarization.getByLabel('Trigger: value')).toHaveValue('0.8'); + await expect( + summarization.getByText("Summarizes once 80% of the model's context window is used."), + ).toBeVisible(); + const dialog = await review(page); + await expect(dialog).toContainText('Token ratio'); + await saveAndWait(page); + + await summarization.getByLabel('Trigger: when to summarize').selectOption({ index: 0 }); + await review(page); + await saveAndWait(page); + }); + + test('content filters are a read-only summary', async ({ page }) => { + await openConfigTab(page, 'features'); + const filters = section(page, 'filters'); + const show = filters.getByRole('button', { name: /show all fields/i }); + if (await show.count()) await show.click(); + await expect(filters.getByText(/librechat\.yaml/).first()).toBeVisible(); + await expect(filters.locator('input, textarea, select')).toHaveCount(0); + }); +}); diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index 005d974c..e9b37898 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -255,6 +255,7 @@ export function ConfigTabContent({ onChange: onFieldChange, onResetField, onDiscardField, + onHideField: sectionDisabled ? undefined : onHideField, editedValues, disabled: sectionDisabled, previewMode, @@ -273,8 +274,7 @@ export function ConfigTabContent({ yamlBaseKeys: baseRecordKeys?.[dataKey], onValidationError, editSessionId, - onHideField, - onUnhideField, + onUnhideField: sectionDisabled ? undefined : onUnhideField, onRenameEntry, }; const content = ( diff --git a/src/components/configuration/controls.test.ts b/src/components/configuration/controls.test.ts index 3cf9f8ce..a44384ff 100644 --- a/src/components/configuration/controls.test.ts +++ b/src/components/configuration/controls.test.ts @@ -271,7 +271,7 @@ describe('constraints helpers', () => { describe('formatFieldValue / getDefaultHint', () => { it('shows option labels, units and grouped numbers', () => { const retention = schemaField('interface.retentionMode'); - expect(getDefaultHint(retention, 'temporary', localize)).toBe('Temporary'); + expect(getDefaultHint(retention, 'temporary', localize)).toBe('Temporary chats only'); expect(getDefaultHint(schemaField('balance.startBalance'), 20000, localize)).toBe( '20,000 credits', ); diff --git a/src/components/configuration/controls.ts b/src/components/configuration/controls.ts index 239f975a..0226908c 100644 --- a/src/components/configuration/controls.ts +++ b/src/components/configuration/controls.ts @@ -278,6 +278,7 @@ export function canRoundTrip( return (getFieldShape(field).values ?? []).some((member) => member === value); case 'array': case 'multi-enum': + case 'domain-list': return isStringList(value); case 'model-list': return Array.isArray(value) && value.every(isModelItem); @@ -363,6 +364,7 @@ export function fallbackControl( case 'literal': return 'select'; case 'multi-enum': + case 'domain-list': return 'array'; case 'model-list': return value == null || isStringList(value) ? 'array' : 'code'; diff --git a/src/components/configuration/fields/DomainListField.tsx b/src/components/configuration/fields/DomainListField.tsx new file mode 100644 index 00000000..dc185c63 --- /dev/null +++ b/src/components/configuration/fields/DomainListField.tsx @@ -0,0 +1,240 @@ +import { Button } from '@clickhouse/click-ui'; +import { useId, useRef, useState } from 'react'; +import type * as t from '@/types'; +import { cn, domainIssue, normalizeDomain, splitDomainEntries } from '@/utils'; +import { DecorativeIcon } from '@/components/shared'; +import { getDomainListPolicy } from '@/constants'; +import { INPUT_CLASSES } from './display'; +import { FieldNotice } from './Notice'; +import { useLocalize } from '@/hooks'; + +/** Separators that make typed text a list of entries. */ +const LIST_SEPARATOR = /[,;\n]/; + +const ISSUE_KEYS: Record = { + invalid: 'com_config_features_domains_invalid', + wildcard: 'com_config_features_domains_wildcard', + path: 'com_config_features_domains_path', + protocol: 'com_config_features_domains_protocol', + duplicate: 'com_config_features_domains_duplicate', +}; + +/** + * An allowlist of domains (FX-16): each entry is a chip with Remove, new + * entries are validated as LibreChat parses them (host, optional scheme, + * port or leading `*.`) before they are added on Enter, comma or Add, and + * pasting a list adds every valid entry. A status line says what the list does: nothing restricts while + * it is empty, so removing the last entry asks first, and removes the edit + * target's own list (D3: an empty list is never written over the list the + * layer beneath sets, which then applies again). The last entry of a list + * only the layer beneath sets can't be removed here, and the field says so. + */ +export function DomainListField({ + id, + path, + values, + onChange, + onClear, + lowerValues, + hasOwnValue, + lowerSource, + disabled, + 'aria-label': ariaLabel, +}: t.DomainListFieldProps) { + const localize = useLocalize(); + const statusId = useId(); + const errorId = useId(); + const inputRef = useRef(null); + const policy = getDomainListPolicy(path); + const [draft, setDraft] = useState(''); + const [error, setError] = useState(null); + const [confirmingLast, setConfirmingLast] = useState(false); + const [notice, setNotice] = useState(null); + const name = ariaLabel ?? id; + const lowerCount = lowerValues?.length ?? 0; + const source = localize( + lowerSource === 'inherited' + ? 'com_config_features_source_base' + : 'com_config_features_source_yaml', + ); + + /** Adds the valid entries; the rejected ones stay in the input with the first reason. */ + const addEntries = (entries: string[]) => { + const accepted: string[] = []; + const rejected: string[] = []; + let firstIssue: t.DomainIssue | null = null; + for (const entry of entries) { + const issue = domainIssue(entry, policy.protocols, [...values, ...accepted]); + if (issue) { + rejected.push(entry); + firstIssue ??= issue; + continue; + } + accepted.push(normalizeDomain(entry)); + } + if (accepted.length > 0) onChange([...values, ...accepted]); + setDraft(rejected.join(', ')); + setError(firstIssue ? localize(ISSUE_KEYS[firstIssue], { value: rejected[0] ?? '' }) : null); + }; + + /** A typed entry is one domain; commas or semicolons separate several. */ + const commitDraft = () => { + if (draft.trim() === '') { + setDraft(''); + setError(null); + return; + } + addEntries(LIST_SEPARATOR.test(draft) ? splitDomainEntries(draft) : [draft.trim()]); + }; + + const clearList = () => { + setConfirmingLast(false); + if (onClear() === false) { + setNotice(localize('com_config_features_domains_keep_one', { source })); + } + }; + + const remove = (index: number) => { + setNotice(null); + if (values.length > 1) { + onChange(values.filter((_, i) => i !== index)); + inputRef.current?.focus(); + return; + } + if (!hasOwnValue && lowerCount > 0) { + setNotice(localize('com_config_features_domains_keep_one', { source })); + return; + } + setConfirmingLast(true); + }; + + const confirmText = + lowerCount > 0 + ? localize('com_config_features_domains_confirm_restore', { count: lowerCount, source }) + : localize(policy.confirmKey); + + const status = localize(values.length === 0 ? policy.emptyKey : policy.activeKey, { + count: values.length, + }); + const describedBy = [statusId, error ? errorId : undefined].filter(Boolean).join(' '); + + return ( +
+

+ + + + {status} +

+ {values.length > 0 && ( +
    + {values.map((value, index) => ( +
  • + {value} + {!disabled && ( + + )} +
  • + ))} +
+ )} + {confirmingLast && ( +
+ {confirmText} +
+ )} + {!disabled && ( +
+ { + setDraft(event.target.value); + setError(null); + }} + onPaste={(event) => { + const pasted = event.clipboardData.getData('text'); + const entries = splitDomainEntries(pasted); + if (entries.length < 2 || draft.trim() !== '') return; + event.preventDefault(); + addEntries(entries); + }} + onKeyDown={(event) => { + if (event.key === 'Escape' && draft !== '') { + event.preventDefault(); + setDraft(''); + setError(null); + return; + } + if (event.key !== 'Enter' && event.key !== ',') return; + event.preventDefault(); + commitDraft(); + }} + onBlur={() => { + if (draft.trim() !== '') commitDraft(); + }} + /> +
+ )} + {error && ( + + {error} + + )} + {notice && {notice}} +
+ ); +} diff --git a/src/components/configuration/fields/__tests__/DomainListField.test.tsx b/src/components/configuration/fields/__tests__/DomainListField.test.tsx new file mode 100644 index 00000000..2040fc76 --- /dev/null +++ b/src/components/configuration/fields/__tests__/DomainListField.test.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type * as t from '@/types'; +import { DomainListField } from '../DomainListField'; +import '@/locales/i18n'; + +function Harness({ + initial = [], + onClear = () => true, + ...props +}: Partial & { initial?: string[] }) { + const [values, setValues] = useState(initial); + return ( + + ); +} + +const input = () => screen.getByLabelText('Allowed domains: add a domain'); + +function add(text: string) { + fireEvent.change(input(), { target: { value: text } }); + fireEvent.keyDown(input(), { key: 'Enter' }); +} + +describe('DomainListField', () => { + it('gives the row id to its text input, so the row label focuses it', () => { + render( + <> + + + , + ); + expect(document.getElementById('actions-allowedDomains')).toBe(input()); + expect(screen.getByText('Allowed domains row')).toHaveProperty('control', input()); + }); + + it('explains that an empty list means no restriction', () => { + render(); + expect( + screen.getByText(/^No restriction: agent actions can call any public domain/), + ).toBeVisible(); + }); + + it('adds valid domains as chips and says the list now restricts', () => { + const onChange = vi.fn(); + render(); + add('API.Example.com'); + expect(onChange).toHaveBeenLastCalledWith(['api.example.com']); + }); + + it('never adds a blank entry, and rejects invalid ones with a reason', () => { + const onChange = vi.fn(); + render(); + add(' '); + expect(onChange).not.toHaveBeenCalled(); + add('example.com/api'); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent('enter the domain only'); + add('wss://ws.example.com'); + expect(screen.getByRole('alert')).toHaveTextContent('only accepts these schemes'); + }); + + it('adds every valid entry of a pasted list and keeps the invalid ones for fixing', () => { + render(); + fireEvent.paste(input(), { clipboardData: { getData: () => 'a.com, b.com\nbad_host' } }); + expect(screen.getByRole('list', { name: 'Allowed domains' })).toHaveTextContent('a.comb.com'); + expect(input()).toHaveValue('bad_host'); + }); + + it('reads typed text with a space as one entry, not a list', () => { + const onChange = vi.fn(); + render(); + add('not a/domain'); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent('is not a domain'); + expect(input()).toHaveValue('not a/domain'); + }); + + it('adds a typed entry on comma', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(input(), { target: { value: 'example.com' } }); + fireEvent.keyDown(input(), { key: ',' }); + expect(onChange).toHaveBeenLastCalledWith(['example.com']); + }); + + it('rejects a duplicate', () => { + render(); + add('EXAMPLE.com'); + expect(screen.getByRole('alert')).toHaveTextContent('already in the list'); + }); + + it('asks before removing the last domain, then removes the own list', () => { + const onClear = vi.fn(() => true); + render(); + expect(screen.getByText(/^Only these domains are allowed \(1\)/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Remove example.com' })); + expect(onClear).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toHaveTextContent('Remove the last domain?'); + fireEvent.click(screen.getByRole('button', { name: 'Remove restriction' })); + expect(onClear).toHaveBeenCalled(); + }); + + it('says the librechat.yaml list applies again when the own list is removed', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove mine.com' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent( + 'The librechat.yaml list (2 domains) applies again.', + ); + }); + + it('cannot remove the last domain of a list only librechat.yaml sets (D3)', () => { + const onClear = vi.fn(() => false); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove a.com' })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(onClear).not.toHaveBeenCalled(); + expect(screen.getByRole('status')).toHaveTextContent("its last domain can't be removed here"); + }); +}); diff --git a/src/components/configuration/fields/display.ts b/src/components/configuration/fields/display.ts index baeb181f..f0573a0d 100644 --- a/src/components/configuration/fields/display.ts +++ b/src/components/configuration/fields/display.ts @@ -1,8 +1,13 @@ import type * as t from '@/types'; -/** Single-line input look shared by native inputs and selects (matches the click-ui fields' 32px height). */ +/** + * Single-line input look shared by native inputs and selects: the click-ui + * fields' 32px height and their field tokens (background, stroke, text and + * placeholder), so a masked key or a select sits next to a click-ui text + * field without a different fill in either theme. + */ export const INPUT_CLASSES = - 'h-8 w-full min-w-0 rounded-(--cui-radii-sm) border border-solid border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 text-sm text-(--cui-color-text-default) outline-none placeholder:text-(--cui-color-text-muted) focus:border-(--cui-color-stroke-intense) disabled:cursor-default disabled:border-transparent disabled:bg-transparent aria-invalid:border-(--cui-color-text-danger)'; + 'h-8 w-full min-w-0 rounded-(--cui-radii-sm) border border-solid border-(--click-field-color-stroke-default) bg-(--click-field-color-background-default) px-3 text-sm text-(--click-field-color-text-default) outline-none placeholder:text-(--click-field-color-placeholder-default) hover:border-(--click-field-color-stroke-hover) focus:border-(--click-field-color-stroke-active) focus:bg-(--click-field-color-background-active) disabled:cursor-default disabled:border-transparent disabled:bg-transparent aria-invalid:border-(--cui-color-text-danger)'; /** Width classes for a control (S: short numbers, M: identifiers, L: URLs and prompts). */ export function widthClass(width?: t.ControlWidth): string { diff --git a/src/components/configuration/fields/index.ts b/src/components/configuration/fields/index.ts index ef37f686..0be4b7ee 100644 --- a/src/components/configuration/fields/index.ts +++ b/src/components/configuration/fields/index.ts @@ -5,6 +5,7 @@ export { TextareaField } from './TextareaField'; export { ToggleField } from './ToggleField'; export { SelectField } from './SelectField'; export { ListField } from './ListField'; +export { DomainListField } from './DomainListField'; export { NumberListField } from './NumberListField'; export { KeyValueField } from './KeyValueField'; export { CodeField } from './CodeField'; diff --git a/src/components/configuration/meta/features.ts b/src/components/configuration/meta/features.ts new file mode 100644 index 00000000..0f157465 --- /dev/null +++ b/src/components/configuration/meta/features.ts @@ -0,0 +1,258 @@ +import type * as t from '@/types'; + +/** Labels and help for the interface settings, read in their groups (FX-5). */ +const INTERFACE_META: t.FieldMetaRegistry = { + 'interface.customWelcome': { labelKey: 'com_config_label_interface_customWelcome' }, + 'interface.privacyPolicy.externalUrl': { labelKey: 'com_config_label_privacy_url' }, + 'interface.privacyPolicy.openNewTab': { + labelKey: 'com_config_label_privacy_new_tab', + descriptionKey: 'com_config_desc_interface_openNewTab', + }, + 'interface.termsOfService': { + order: ['externalUrl', 'openNewTab', 'modalTitle', 'modalContent', 'modalAcceptance'], + }, + 'interface.termsOfService.externalUrl': { labelKey: 'com_config_label_terms_url' }, + 'interface.termsOfService.openNewTab': { + labelKey: 'com_config_label_terms_new_tab', + descriptionKey: 'com_config_desc_interface_openNewTab', + }, + 'interface.termsOfService.modalTitle': { + labelKey: 'com_config_label_terms_title', + descriptionKey: 'com_config_desc_interface_termsOfService_modalTitle', + }, + 'interface.termsOfService.modalContent': { labelKey: 'com_config_label_terms_content' }, + 'interface.termsOfService.modalAcceptance': { labelKey: 'com_config_label_terms_acceptance' }, + 'interface.retentionMode': { + enumLabels: { + all: 'com_config_enum_retention_all', + temporary: 'com_config_enum_retention_temporary', + }, + }, + 'interface.generalChatRetention': { + descriptionKey: 'com_config_desc_interface_generalChatRetention_all', + }, + 'interface.retainAgentFiles': { descriptionKey: 'com_config_desc_interface_retainAgentFiles' }, + 'interface.currency': { + labelKey: 'com_config_label_interface_currency', + descriptionKey: 'com_config_desc_interface_currency', + }, + 'interface.currency.code': { descriptionKey: 'com_config_desc_interface_currency_code' }, + 'interface.currency.rate': { descriptionKey: 'com_config_desc_interface_currency_rate' }, + 'interface.buildInfo': { descriptionKey: 'com_config_desc_interface_buildInfo' }, + 'interface.sharedLinks.snapshotFiles': { + labelKey: 'com_config_label_sharedLinks_snapshotFiles', + descriptionKey: 'com_config_desc_interface_sharedLinks_snapshotFiles', + runtimeDefault: true, + }, + 'interface.skills.defaultActiveOnShare': { + labelKey: 'com_config_label_skills_defaultActiveOnShare', + descriptionKey: 'com_config_desc_interface_skills_defaultActiveOnShare', + runtimeDefault: false, + }, + 'interface.traceViewer': { + order: [ + 'enabled', + 'showInputOutput', + 'showToolNames', + 'maxRecords', + 'maxContentLength', + 'requestsPerMinute', + 'requestTimeoutMs', + ], + }, + 'interface.traceViewer.enabled': { + labelKey: 'com_config_label_traceViewer_enabled', + descriptionKey: 'com_config_desc_interface_traceViewer_enabled', + }, + 'interface.traceViewer.showInputOutput': { + descriptionKey: 'com_config_desc_interface_traceViewer_showInputOutput', + }, + 'interface.traceViewer.showToolNames': { + descriptionKey: 'com_config_desc_interface_traceViewer_showToolNames', + }, + 'interface.traceViewer.maxRecords': { + runtimeDefault: 1000, + descriptionKey: 'com_config_desc_interface_traceViewer_maxRecords', + }, + 'interface.traceViewer.maxContentLength': { + runtimeDefault: 50_000, + descriptionKey: 'com_config_desc_interface_traceViewer_maxContentLength', + }, + 'interface.traceViewer.requestsPerMinute': { + runtimeDefault: 30, + descriptionKey: 'com_config_desc_interface_traceViewer_requestsPerMinute', + }, + 'interface.traceViewer.requestTimeoutMs': { runtimeDefault: 10_000 }, + 'interface.mcpServers': { + order: ['placeholder', 'toolsRefreshInterval', 'statusRefreshInterval', 'trustCheckbox'], + }, + 'interface.mcpServers.toolsRefreshInterval': { + unit: 'ms', + runtimeDefault: 300_000, + descriptionKey: 'com_config_desc_interface_mcpServers_toolsRefreshInterval', + }, + 'interface.mcpServers.statusRefreshInterval': { + unit: 'ms', + runtimeDefault: 30_000, + descriptionKey: 'com_config_desc_interface_mcpServers_statusRefreshInterval', + }, + 'interface.mcpServers.trustCheckbox': { + labelKey: 'com_config_label_mcp_trustCheckbox', + descriptionKey: 'com_config_desc_interface_mcpServers_trustCheckbox', + }, +}; + +/** + * Memory (FX-3): `disabled` is shown as the positive "Enable memory" switch, + * so its values are named the way that switch reads them. + */ +const MEMORY_META: t.FieldMetaRegistry = { + memory: { + order: [ + 'disabled', + 'personalize', + 'validKeys', + 'messageWindowSize', + 'tokenLimit', + 'charLimit', + 'maxInputTokens', + ], + }, + 'memory.disabled': { + labelKey: 'com_config_label_memory_enabled', + descriptionKey: 'com_config_desc_memory_enabled', + enumLabels: { true: 'com_config_values_memory_off', false: 'com_config_values_memory_on' }, + }, + 'memory.charLimit': { descriptionKey: 'com_config_desc_memory_charLimit' }, + 'memory.maxInputTokens': { descriptionKey: 'com_config_desc_memory_maxInputTokens' }, + 'memory.agent.enabled': { + labelKey: 'com_config_label_memory_agent_enabled', + descriptionKey: 'com_config_desc_memory_agent_enabled', + }, + 'memory.agent.id': { + labelKey: 'com_config_label_memory_agent_id', + descriptionKey: 'com_config_desc_memory_agent_id', + }, + 'memory.agent.provider': { descriptionKey: 'com_config_desc_memory_agent_provider' }, + 'memory.agent.model': { descriptionKey: 'com_config_desc_memory_agent_model' }, + 'memory.agent.instructions': { + multiline: true, + descriptionKey: 'com_config_desc_memory_agent_instructions', + }, + 'memory.agent.model_parameters': { + descriptionKey: 'com_config_desc_memory_agent_model_parameters', + }, +}; + +/** Summarization (FX-4): when it runs first, then which model writes it and how. */ +const SUMMARIZATION_META: t.FieldMetaRegistry = { + summarization: { + order: [ + 'enabled', + 'trigger', + 'provider', + 'model', + 'maxSummaryTokens', + 'reserveRatio', + 'prompt', + 'updatePrompt', + 'parameters', + ], + }, + 'summarization.updatePrompt': { descriptionKey: 'com_config_desc_summarization_updatePrompt' }, + 'summarization.parameters': { descriptionKey: 'com_config_desc_summarization_parameters' }, +}; + +/** Web search (FX-2): unset service choices mean LibreChat picks one with credentials. */ +const WEB_SEARCH_META: t.FieldMetaRegistry = { + 'webSearch.searchProvider': { defaultLabelKey: 'com_config_features_ws_automatic' }, + 'webSearch.scraperProvider': { defaultLabelKey: 'com_config_features_ws_automatic' }, + 'webSearch.rerankerType': { defaultLabelKey: 'com_config_features_ws_automatic' }, + 'webSearch.scraperTimeout': { + unit: 'ms', + runtimeDefault: 7500, + descriptionKey: 'com_config_desc_webSearch_scraperTimeout', + }, + 'webSearch.safeSearch': { descriptionKey: 'com_config_desc_webSearch_safeSearch_features' }, + 'webSearch.keenableApiKey': { secret: true }, +}; + +/** + * Allowlists of domains (FX-16): validated chips; an empty list means no + * restriction. An empty list is never written over a list the layer beneath + * sets (D3): removing the last entry removes the target's own list. + */ +const ALLOWLIST_META: t.FieldMetaRegistry = { + 'actions.allowedDomains': { + control: 'domain-list', + descriptionKey: 'com_config_desc_actions_allowedDomains_list', + }, + 'mcpSettings.allowedDomains': { + control: 'domain-list', + descriptionKey: 'com_config_desc_mcpSettings_allowedDomains_list', + }, +}; + +/** Speech (FX-14): which settings are providers and which are each user's starting values. */ +const SPEECH_META: t.FieldMetaRegistry = { + 'speech.tts': { labelKey: 'com_config_label_speech_tts' }, + 'speech.stt': { labelKey: 'com_config_label_speech_stt' }, + 'speech.speechTab': { + labelKey: 'com_config_label_speech_speechTab', + descriptionKey: 'com_config_desc_speech_speechTab', + }, + 'speech.speechTab.speechToText.engineSTT': { + labelKey: 'com_config_label_speech_engine', + descriptionKey: 'com_config_desc_speech_engineSTT', + }, + 'speech.speechTab.textToSpeech.engineTTS': { + labelKey: 'com_config_label_speech_engine', + descriptionKey: 'com_config_desc_speech_engineTTS', + }, + 'speech.speechTab.speechToText.languageSTT': { + labelKey: 'com_config_label_speech_language', + descriptionKey: 'com_config_desc_speech_language', + }, + 'speech.speechTab.textToSpeech.languageTTS': { + labelKey: 'com_config_label_speech_language', + descriptionKey: 'com_config_desc_speech_language', + }, + 'speech.speechTab.speechToText.decibelValue': { + width: 'S', + labelKey: 'com_config_label_speech_decibelValue', + descriptionKey: 'com_config_desc_speech_decibelValue', + }, + 'speech.speechTab.speechToText.autoSendText': { + unit: 's', + width: 'S', + labelKey: 'com_config_label_speech_autoSendText', + descriptionKey: 'com_config_desc_speech_autoSendText', + }, + 'speech.speechTab.textToSpeech.playbackRate': { + descriptionKey: 'com_config_desc_speech_playbackRate', + }, + 'speech.speechTab.textToSpeech.cacheTTS': { + labelKey: 'com_config_label_speech_cacheTTS', + descriptionKey: 'com_config_desc_speech_cacheTTS', + }, + 'speech.speechTab.conversationMode': { + descriptionKey: 'com_config_desc_speech_conversationMode', + }, + 'speech.speechTab.advancedMode': { descriptionKey: 'com_config_desc_speech_advancedMode' }, + 'speech.tts.allowedAddresses': { descriptionKey: 'com_config_desc_speech_allowedAddresses' }, + 'speech.stt.allowedAddresses': { descriptionKey: 'com_config_desc_speech_allowedAddresses' }, +}; + +/** + * Field metadata of the Features tab (WS13): interface grouping labels, + * memory's positive switch, summarization order, web search defaults, + * allowlist chips and speech labels. + */ +export const FEATURES_FIELD_META: t.FieldMetaRegistry = { + ...INTERFACE_META, + ...MEMORY_META, + ...SUMMARIZATION_META, + ...WEB_SEARCH_META, + ...ALLOWLIST_META, + ...SPEECH_META, +}; diff --git a/src/components/configuration/meta/index.ts b/src/components/configuration/meta/index.ts index 6fbf9bf7..9696981f 100644 --- a/src/components/configuration/meta/index.ts +++ b/src/components/configuration/meta/index.ts @@ -2,6 +2,7 @@ import type * as t from '@/types'; import { buildFieldMetaRegistry, resolveFieldMeta } from './registry'; import { PROVIDER_FIELD_META } from './providers'; import { SECRET_CONFIG_KEYS } from '@/constants'; +import { FEATURES_FIELD_META } from './features'; import { ENDPOINT_FIELD_META } from './endpoints'; import { GENERIC_FIELD_META } from './generic'; import { SPEC_FIELD_META } from './specs'; @@ -14,6 +15,7 @@ const META_SOURCES: t.FieldMetaRegistry[] = [ ENDPOINT_FIELD_META, SPEC_FIELD_META, MCP_FIELD_META, + FEATURES_FIELD_META, ]; const REGISTRY = buildFieldMetaRegistry(META_SOURCES); diff --git a/src/components/configuration/renderer/FieldControl.tsx b/src/components/configuration/renderer/FieldControl.tsx index ef34b1fa..70b33e56 100644 --- a/src/components/configuration/renderer/FieldControl.tsx +++ b/src/components/configuration/renderer/FieldControl.tsx @@ -21,6 +21,7 @@ import { ScalarUnionField } from '../fields/ScalarUnionField'; import { BoolOrListField } from '../fields/BoolOrListField'; import { TextRecordField } from '../fields/TextRecordField'; import { ListRecordField } from '../fields/ListRecordField'; +import { DomainListField } from '../fields/DomainListField'; import { ModelListField } from '../fields/ModelListField'; import { MultiEnumField } from '../fields/MultiEnumField'; import { RefSelectField } from '../fields/RefSelectField'; @@ -441,6 +442,19 @@ export function FieldControl({ /> ); } + case 'domain-list': + return ( + + ); case 'record': return ( onChange(path, next), + onClear: clear, + fieldDefault, + disabled, + required: !field.isOptional, + secretPreview: showsStoredSecret ? secretPreviewValue : null, + hasPendingEdit, + onCancelSecret: () => onDiscardField?.(path), + editSessionId, + }; + + if (renderControl) { + return ( + + {renderControl(controlProps)} + + ); + } + if (kind === 'array-object') { const ownsList = getKeyedCollection(path)?.merge === 'replace' && @@ -288,20 +328,7 @@ export function SingleFieldRenderer({ control = ( onChange(path, next)} - onClear={clear} - fieldDefault={fieldDefault} - disabled={disabled} - required={!field.isOptional} - secretPreview={showsStoredSecret ? secretPreviewValue : null} - hasPendingEdit={hasPendingEdit} - onCancelSecret={() => onDiscardField?.(path)} - editSessionId={editSessionId} + {...controlProps} /> ); } diff --git a/src/components/configuration/renderer/__tests__/SingleField.test.tsx b/src/components/configuration/renderer/__tests__/SingleField.test.tsx new file mode 100644 index 00000000..80518a68 --- /dev/null +++ b/src/components/configuration/renderer/__tests__/SingleField.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { fireEvent, screen, within } from '@testing-library/react'; +import { renderSection } from '../../sections/features/__tests__/harness'; +import { FieldRenderer } from '../FieldRenderer'; +import '@/locales/i18n'; + +const YAML = { actions: { allowedDomains: ['a.com'] } }; + +describe('a domain allowlist the layer beneath sets', () => { + it('drops a pending replacement list when its last domain is removed, so the lower list applies again', () => { + const { onDiscardField } = renderSection(FieldRenderer, { section: 'actions', yaml: YAML }); + const input = screen.getByLabelText(/add a domain/); + fireEvent.change(input, { target: { value: 'b.com' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: 'Remove a.com' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove b.com' })); + const confirm = screen.getByRole('alertdialog', { name: 'Remove the restriction' }); + expect(confirm).toHaveTextContent('applies again'); + fireEvent.click(within(confirm).getByRole('button', { name: 'Remove restriction' })); + expect(onDiscardField).toHaveBeenCalledWith('actions.allowedDomains'); + expect(screen.getByRole('button', { name: 'Remove a.com' })).toBeInTheDocument(); + expect(screen.queryByText(/its last domain can't be removed here/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/configuration/reveal.test.ts b/src/components/configuration/reveal.test.ts index b5bc96ba..a59042dd 100644 --- a/src/components/configuration/reveal.test.ts +++ b/src/components/configuration/reveal.test.ts @@ -85,6 +85,30 @@ describe('revealConfigPath', () => { expect(container.scrollTo).toHaveBeenCalled(); }); + it('opens the collapsed group around a target whose own id is already rendered', () => { + const group = document.createElement('section'); + group.id = 'section-interface-group-onboarding'; + group.innerHTML = html`
+
+

Terms of service

+
`; + group.addEventListener('config:expand', () => { + const grid = group.querySelector('.config-section-grid')!; + grid.classList.replace('config-section-grid-closed', 'config-section-grid-open'); + grid.removeAttribute('inert'); + }); + container.append(group); + + const onDone = vi.fn(); + revealConfigPath(container, { path: 'interface.termsOfService' }, { onDone, focus: false }); + vi.advanceTimersByTime(2_000); + + expect(onDone).toHaveBeenCalledWith( + document.getElementById('section-interface.termsOfService'), + ); + expect(group.querySelector('[inert]')).toBeNull(); + }); + it('gives up with null when the path never renders', () => { const onDone = vi.fn(); revealConfigPath(container, { path: 'nothing.here' }, { onDone }); diff --git a/src/components/configuration/reveal.ts b/src/components/configuration/reveal.ts index c1a0f952..23d8c589 100644 --- a/src/components/configuration/reveal.ts +++ b/src/components/configuration/reveal.ts @@ -205,6 +205,9 @@ export function revealConfigPath( } let changed = false; + /** The target exists but sits in a collapsed group no path names (a purpose group around flat rows). */ + const enclosing = el && !isShown(el) ? el.closest(CLOSED_GRID)?.parentElement : null; + if (enclosing instanceof HTMLElement) changed = expandContainer(enclosing, tried); if (target.sectionId) { const section = byId(container.ownerDocument, `section-${target.sectionId}`); if (section && container.contains(section)) changed = expandContainer(section, tried); diff --git a/src/components/configuration/review/group.ts b/src/components/configuration/review/group.ts index 27655441..d347ddad 100644 --- a/src/components/configuration/review/group.ts +++ b/src/components/configuration/review/group.ts @@ -5,8 +5,8 @@ import { flattenObject, getValueAtPath, isConfigRecord, findKeyedCollectionEntry import { identityKeyFor, isSecretPath, findEntry } from './diff'; import { SKIP_REASON_KEYS, getConfigSection } from '@/constants'; import { findSchemaField, getFieldOptions } from '../controls'; +import { getFieldMeta, labelForPath } from '../meta'; import { fieldVariants } from '../renderer/kinds'; -import { labelForPath } from '../meta'; /** Locale keys for what an entry of a known collection is called. */ const NOUN_KEYS: Readonly> = { @@ -201,6 +201,12 @@ function valueLabelsAt( const discriminated = discriminatorLabelsAt(path, ctx); if (discriminated) return discriminated; const field = findSchemaField(ctx.fields, path); + const metaLabels = field?.type === 'boolean' ? getFieldMeta(path)?.enumLabels : undefined; + if (metaLabels) { + return Object.fromEntries( + Object.entries(metaLabels).map(([value, key]) => [value, ctx.localize(key)]), + ); + } if (!field || !(field.type.startsWith('enum') || field.type.startsWith('union(literal'))) { return undefined; } diff --git a/src/components/configuration/review/review.test.ts b/src/components/configuration/review/review.test.ts index 37c4e25b..8d7b4b96 100644 --- a/src/components/configuration/review/review.test.ts +++ b/src/components/configuration/review/review.test.ts @@ -567,7 +567,7 @@ describe('buildReviewModel', () => { 'Custom endpoints › Zai 2 › Base URL', ); expect(describePath('interface.termsOfService.modalTitle', ctx([]))).toBe( - 'Features › Interface › Terms of service › Modal title', + 'Features › Interface › Terms of service › Terms dialog title', ); }); diff --git a/src/components/configuration/sections/features/Agent.tsx b/src/components/configuration/sections/features/Agent.tsx new file mode 100644 index 00000000..52c681bc --- /dev/null +++ b/src/components/configuration/sections/features/Agent.tsx @@ -0,0 +1,200 @@ +import { 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 { NestedGroup, toFieldId } from '../../renderer'; +import { MEMORY_AGENT_KEYS } from '@/constants'; +import { countConfigured } from '../../counts'; +import { ConfigRow } from '../../ConfigRow'; +import { labelForPath } from '../../meta'; +import { FeatureFields } from './Fields'; +import { RefPicker } from './Picker'; + +const MODES: readonly t.MemoryAgentMode[] = ['id', 'inline']; +/** No definition: nothing pressed. An undefined `selected` would leave the group tracking clicks itself. */ +const NO_MODE = ''; + +function Note({ tone = 'muted', children }: t.AgentNoteProps) { + return ( +

+ {children} +

+ ); +} + +/** + * The memory agent (FX-3): "Save memories automatically", then how the agent + * is defined, either an existing agent by ID or a provider and model with + * instructions, and only that definition's fields. LibreChat merges an + * override over the layer beneath key by key, so switching away from a + * definition librechat.yaml (or Base) makes hides its keys, which the save + * writes before the new values; without permission to hide them the editor + * says that only librechat.yaml can change it. + */ +export function MemoryAgent({ field, ...props }: t.MemoryAgentProps) { + const localize = useLocalize(); + const scope = useValuesScope(); + const visibility = usePendingVisibility(); + const path = `${props.parentPath}.${field.key}`; + const children = field.children ?? []; + const [paths] = useState(() => children.map((child) => `${path}.${child.key}`)); + const states = useFieldStates(paths); + const [chosen, setChosen] = useState(undefined); + const [blocked, setBlocked] = useState(false); + /** A definition chosen without values yet belongs to its edit session: Discard or another scope forgets it. */ + useEffect(() => { + setChosen(undefined); + setBlocked(false); + }, [props.editSessionId, scope?.name]); + + const hidden = new Set( + children + .filter((child) => { + const childPath = `${path}.${child.key}`; + return isAgentKeyHidden(states[childPath], visibility[childPath]); + }) + .map((child) => child.key), + ); + const visibleValue: t.ConfigRecord = {}; + for (const child of children) { + if (hidden.has(child.key)) continue; + const value = props.getValue(`${path}.${child.key}`, undefined); + if (value !== undefined) visibleValue[child.key] = value; + } + const derived = agentMode(visibleValue); + const mode = derived ?? chosen; + const pendingHides = paths.filter((childPath) => visibility[childPath] === 'hide'); + const storedHides = paths.filter( + (childPath) => states[childPath]?.isHidden && visibility[childPath] === undefined, + ); + const ownsAgent = + storedHides.length > 0 || paths.some((childPath) => states[childPath]?.isOverride); + const lowerSource = localize( + scope ? 'com_config_features_source_base' : 'com_config_features_source_yaml', + ); + const keyLabels = (list: string[]) => + list.map((childPath) => labelForPath(childPath, localize)).join(', '); + + const switchTo = (next: string) => { + if (next !== 'id' && next !== 'inline') return; + if (next === mode) return; + const plan = planAgentSwitch(next, path, states, visibility, props.editedValues); + const cannotHide = plan.hide.length > 0 && !props.onHideField; + if (cannotHide || (plan.show.length > 0 && !props.onUnhideField)) { + setBlocked(true); + return; + } + setBlocked(false); + for (const keyPath of [...plan.unhide, ...plan.discard]) props.onDiscardField?.(keyPath); + for (const keyPath of plan.reset) props.onChange(keyPath, undefined); + for (const keyPath of plan.hide) props.onHideField?.(keyPath); + for (const keyPath of plan.show) props.onUnhideField?.(keyPath); + setChosen(next); + }; + + const modeFields = children.filter( + (child) => mode !== undefined && MEMORY_AGENT_KEYS[mode].includes(child.key), + ); + const enabledField = children.filter((child) => child.key === 'enabled'); + const provider = visibleValue.provider; + const controls: t.FeatureControls = { + provider: (control) => , + model: (control) => ( + + ), + }; + const counts = countConfigured(children, path, props.configuredPaths); + const modeId = toFieldId(`${path}-mode`); + + return ( + + + 0} + isDbOverride={ownsAgent} + isPendingReset={props.pendingResets?.has(path)} + disabled={props.disabled} + > +
+ ({ + value, + label: localize(`com_config_features_memory_mode_${value}`), + disabled: props.disabled, + }))} + onClick={(value) => { + if (!props.disabled) switchTo(value); + }} + /> + {mode === undefined && {localize('com_config_features_memory_mode_none')}} + {pendingHides.length > 0 && ( + + {localize('com_config_features_memory_hides', { + keys: keyLabels(pendingHides), + source: lowerSource, + })} + + )} + {storedHides.length > 0 && ( + + {localize('com_config_features_memory_hidden', { + keys: keyLabels(storedHides), + source: lowerSource, + })} + + )} + {blocked && ( + + {localize('com_config_features_memory_blocked', { source: lowerSource })} + + )} +
+
+ {modeFields.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/configuration/sections/features/Credential.tsx b/src/components/configuration/sections/features/Credential.tsx new file mode 100644 index 00000000..de12928e --- /dev/null +++ b/src/components/configuration/sections/features/Credential.tsx @@ -0,0 +1,164 @@ +import { useId } from 'react'; +import type * as t from '@/types'; +import { SecretField, SecretInput } from '../../fields/SecretField'; +import { FieldControl } from '../../renderer/FieldControl'; +import { DecorativeIcon } from '@/components/shared'; +import { TextField } from '../../fields/TextField'; +import { credentialState } from './state'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +/** `${SERPER_API_KEY}`: how librechat.yaml references the credential's environment variable. */ +function envRef(credential: t.WebSearchCredential): string { + return `\${${credential.env}}`; +} + +const TONE_CLASSES: Record = { + muted: 'text-(--settings-muted-fg)', + ok: 'text-(--settings-success-fg)', + warn: 'text-(--settings-warn-fg)', +}; + +/** The status line of a credential: its source, and whether something is wrong. */ +function statusLine( + state: t.CredentialState, + credential: t.WebSearchCredential, + serviceName: string, + inUse: boolean, + localize: t.LocalizeFn, +): t.CredentialStatusLine | null { + switch (state.status) { + case 'pending': + return credential.encrypted + ? { tone: 'muted', icon: 'lock', text: localize('com_config_features_cred_pending') } + : { + tone: 'warn', + icon: 'warning', + text: localize('com_config_features_cred_plaintext', { ref: envRef(credential) }), + }; + case 'stored': + return { tone: 'ok', icon: 'lock', text: localize('com_config_features_cred_stored') }; + case 'env': + return { + tone: 'ok', + icon: 'key', + text: localize('com_config_features_cred_env', { env: state.env ?? credential.env }), + }; + case 'default': + return { + tone: 'muted', + icon: 'key', + text: localize('com_config_features_cred_default', { env: state.env ?? credential.env }), + }; + case 'set': + return credential.secret && !credential.encrypted + ? { + tone: 'warn', + icon: 'warning', + text: localize('com_config_features_cred_plaintext', { ref: envRef(credential) }), + } + : null; + case 'missing': + return inUse && credential.required + ? { + tone: 'warn', + icon: 'warning', + text: localize('com_config_features_cred_missing', { + service: serviceName, + ref: envRef(credential), + }), + } + : { tone: 'muted', icon: 'key', text: localize('com_config_features_cred_unset') }; + default: + return null; + } +} + +/** + * A web search credential (FX-2): the key or URL a service reads, with where + * its value comes from. A stored key shows only its masked preview with + * Replace; a typed key is masked; an environment reference is shown as is. + * The status line says whether the value is read from an environment + * variable, stored encrypted, queued, or missing for a selected service. + */ +export function CredentialControl({ + credential, + serviceName, + inUse, + ...props +}: t.CredentialControlProps) { + const localize = useLocalize(); + const statusId = useId(); + if (props.disabled) return ; + const state = credentialState({ ...props, credential }); + const line = statusLine(state, credential, serviceName, inUse, localize); + const text = typeof props.value === 'string' ? props.value : ''; + const placeholder = localize( + credential.secret + ? 'com_config_features_cred_key_placeholder' + : 'com_config_features_cred_url_placeholder', + { ref: envRef(credential) }, + ); + const describedBy = line ? statusId : undefined; + + const input = (() => { + if (credential.secret && props.secretPreview != null && props.onCancelSecret) { + return ( + + ); + } + if (credential.secret) { + return ( + + ); + } + return ( + + ); + })(); + + return ( +
+ {input} + {line && ( + + + + + {line.text} + + )} +
+ ); +} diff --git a/src/components/configuration/sections/features/Fields.tsx b/src/components/configuration/sections/features/Fields.tsx new file mode 100644 index 00000000..bbb32a24 --- /dev/null +++ b/src/components/configuration/sections/features/Fields.tsx @@ -0,0 +1,59 @@ +import { useContext } from 'react'; +import type * as t from '@/types'; +import { getSecretPreviewValue, isConfigRecord, isRowVisible } from '@/utils'; +import { FieldRenderer, SingleFieldRenderer } from '../../renderer'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { orderFields } from '../../controls'; + +/** + * A feature section's fields in display order (`FieldMeta.order`). Each field + * renders as the generic renderer would (rows, nested groups, switches), + * except keys listed in `controls`, which keep their settings row (label, + * chips, Reset) around the purpose-built control. + */ +export function FeatureFields({ controls, ...props }: t.FeatureFieldsProps) { + const rowFilter = useContext(RowFilterContext); + const { fields, parentPath, parentValue } = props; + const values = isConfigRecord(parentValue) ? parentValue : {}; + return ( + <> + {orderFields(fields, parentPath).map((field) => { + const path = `${parentPath}.${field.key}`; + const renderControl = controls?.[field.key]; + if (!renderControl) { + return ; + } + if (!isRowVisible(rowFilter, path)) return null; + return ( + + ); + })} + + ); +} diff --git a/src/components/configuration/sections/features/Filters.tsx b/src/components/configuration/sections/features/Filters.tsx new file mode 100644 index 00000000..4c726a66 --- /dev/null +++ b/src/components/configuration/sections/features/Filters.tsx @@ -0,0 +1,196 @@ +import type * as t from '@/types'; +import { getFieldOptions } from '../../controls'; +import { labelForPath } from '../../meta'; +import { isConfigRecord } from '@/utils'; +import { useLocalize } from '@/hooks'; + +const PII_KEY = 'pii'; +const PII_LISTED_KEYS: ReadonlySet = new Set([ + 'action', + 'fields', + 'starterPatterns', + 'customPatterns', +]); + +function childField(field: t.SchemaField | undefined, key: string): t.SchemaField | undefined { + 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( + surface: t.SchemaField, + value: t.ConfigValue, + localize: t.LocalizeFn, +): t.FilterSurfaceSummary { + 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 custom = Array.isArray(pii?.customPatterns) ? pii.customPatterns : []; + return { + key: surface.key, + label: labelForPath(surface.path, localize), + action: pii ? String(pii.action ?? 'block') : undefined, + fields: + pii?.fields == null ? [] : optionLabels(childField(piiField, 'fields'), pii.fields, localize), + starterPatterns: + pii?.starterPatterns == null + ? null + : optionLabels(childField(piiField, 'starterPatterns'), pii.starterPatterns, localize), + customPatterns: custom.map((pattern) => + isConfigRecord(pattern) ? String(pattern.label ?? pattern.id ?? '') : String(pattern), + ), + extras, + }; +} + +function SurfaceCard({ summary }: t.SurfaceCardProps) { + const localize = useLocalize(); + const isAudit = summary.action === 'audit'; + const builtIn = (() => { + if (summary.starterPatterns == null) return localize('com_config_features_filters_all_builtin'); + if (summary.starterPatterns.length === 0) { + return localize('com_config_features_filters_no_builtin'); + } + return summary.starterPatterns.join(', '); + })(); + return ( +
  • +
    + {summary.label} + {summary.action && ( + + {localize( + isAudit ? 'com_config_features_filters_audit' : 'com_config_features_filters_block', + )} + + )} +
    +
    + {summary.action && ( + <> +
    + {localize('com_config_features_filters_scans')} +
    +
    + {summary.fields.length > 0 + ? summary.fields.join(', ') + : localize('com_config_features_filters_all_fields')} +
    +
    + {localize('com_config_features_filters_patterns')} +
    +
    + {summary.customPatterns.length > 0 + ? localize('com_config_features_filters_patterns_custom', { + builtIn, + custom: summary.customPatterns.join(', '), + }) + : builtIn} +
    + + )} + {summary.extras.map((extra) => ( +
    +
    {extra.label}
    +
    {extra.value}
    +
    + ))} +
    +
  • + ); +} + +/** + * Content filters (FX-1). LibreChat reads `filters` only from librechat.yaml + * (`BASE_ONLY_CONFIG_SECTIONS`), so instead of a dozen empty editable groups + * the section summarizes what librechat.yaml sets: per surface, the action + * (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) { + const localize = useLocalize(); + const summaries = fields.map((surface) => + summarizeSurface(surface, getValue(`${parentPath}.${surface.key}`, undefined), localize), + ); + const active = summaries.filter((summary) => summary.action || summary.extras.length > 0); + const inactive = summaries.filter((summary) => !summary.action && summary.extras.length === 0); + return ( +
    + {active.length === 0 ? ( +

    + {localize('com_config_features_filters_none')} +

    + ) : ( +
      + {active.map((summary) => ( + + ))} +
    + )} + {inactive.length > 0 && ( +

    + {localize( + active.length === 0 + ? 'com_config_features_filters_available' + : 'com_config_features_filters_unfiltered', + { surfaces: inactive.map((summary) => summary.label).join(', ') }, + )} +

    + )} +

    + {localize('com_config_features_filters_how')} +

    +
    + ); +} diff --git a/src/components/configuration/sections/features/Group.tsx b/src/components/configuration/sections/features/Group.tsx new file mode 100644 index 00000000..8341f447 --- /dev/null +++ b/src/components/configuration/sections/features/Group.tsx @@ -0,0 +1,132 @@ +import { useContext } from 'react'; +import type * as t from '@/types'; +import { countConfigured, isPathSet } from '../../counts'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { UnknownSettings } from '../../UnknownSettings'; +import { isConfigRecord, isRowVisible } from '@/utils'; +import { NestedGroup } from '../../renderer'; +import { labelForPath } from '../../meta'; +import { FeatureFields } from './Fields'; +import { useLocalize } from '@/hooks'; + +/** The group's fields in its own order; keys the schema doesn't have are skipped. */ +export function groupFields(group: t.FeatureGroupDef, fields: t.SchemaField[]): t.SchemaField[] { + const byKey = new Map(fields.map((field) => [field.key, field])); + return group.fields.flatMap((key) => byKey.get(key) ?? []); +} + +/** The data paths a group shows: its fields, and the children of the ones it flattens. */ +function groupPaths(group: t.FeatureGroupDef, fields: t.SchemaField[], parentPath: string) { + const flatten = new Set(group.flatten); + return fields.flatMap((field) => { + const path = `${parentPath}.${field.key}`; + if (!flatten.has(field.key) || !field.children?.length) return [path]; + return field.children.map((child) => `${path}.${child.key}`); + }); +} + +/** + * A sub-heading and the rows of an object field shown flat inside a group + * (Privacy policy's URL and new-tab switch under "Onboarding & legal"). The + * heading carries the object's section id, so table-of-contents links and + * search still land on it. + */ +function FlatObject({ field, controls, titled, ...props }: t.FlatObjectProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const path = `${props.parentPath}.${field.key}`; + const values = isConfigRecord(props.parentValue) ? props.parentValue : {}; + const value = values[field.key]; + const children = field.children ?? []; + const hidesUnset = + props.showConfiguredOnly && + !props.previewMode && + !isPathSet(path, props.configuredPaths, props.editedValues); + if (!isRowVisible(rowFilter, path) || hidesUnset) return null; + return ( + <> + {titled ? ( +

    + {labelForPath(field.path, localize)} +

    + ) : ( +