diff --git a/e2e/config-shell.spec.ts b/e2e/config-shell.spec.ts index db80994f..d0a34149 100644 --- a/e2e/config-shell.spec.ts +++ b/e2e/config-shell.spec.ts @@ -145,17 +145,17 @@ test.describe('Configuration shell: search and deep links', () => { await expect(page).toHaveURL(/tab=system/); }); - test('Ctrl+K on a custom endpoint name opens its card', async ({ page }) => { + test('Ctrl+K on a custom endpoint name opens its editor', async ({ page }) => { await openTab(page, 'custom'); - const first = page.locator('form [data-entry-card] button[aria-expanded]').first(); - const name = (await first.locator('span[title]').first().getAttribute('title')) ?? ''; + const first = page.locator('form li[data-endpoint-row]').first(); + const name = (await first.getAttribute('data-endpoint-row')) ?? ''; test.skip(!name, 'No custom endpoints'); await openTab(page, 'system'); await search(page, name); - const card = page.locator('form [data-entry-card] button[aria-expanded="true"]', { - hasText: name, + const editor = page.getByRole('dialog'); + await expect(editor.getByRole('heading', { name, exact: true })).toBeVisible({ + timeout: 10_000, }); - await expect(card.first()).toBeVisible({ timeout: 10_000 }); }); test('a deep link to a nested field expands its ancestors', async ({ page }) => { diff --git a/e2e/config.spec.ts b/e2e/config.spec.ts index 25918dd2..e27f4e79 100644 --- a/e2e/config.spec.ts +++ b/e2e/config.spec.ts @@ -67,64 +67,56 @@ test.describe('Configuration: Enter never edits another field', () => { await page.getByRole('button', { name: 'Discard' }).click(); }); - test('Enter in a create dialog still submits that dialog', async ({ page }) => { + test('Enter in the create endpoint form still submits it', async ({ page }) => { await openConfigTab(page, 'custom'); - await page - .getByRole('button', { name: /Create endpoint/ }) - .first() - .click(); - const dialog = page.getByRole('dialog'); - await dialog.locator('#create-endpoint-name').fill('Enter Endpoint'); - await dialog.locator('#create-endpoint-apiKey').fill('key'); - const models = dialog.getByPlaceholder(/Add models/); + await page.getByRole('button', { name: 'Create endpoint' }).first().click(); + const drawer = page.getByRole('dialog'); + await drawer.getByRole('button', { name: /^Blank endpoint/ }).click(); + await drawer.locator('#endpoints-custom-9999-name').fill('Enter Endpoint'); + await drawer.locator('#endpoints-custom-9999-apiKey').fill('key'); + const models = drawer.getByPlaceholder(/Add models/); await models.fill('enter-model'); await models.press('Enter'); - await expect(dialog).toHaveCount(1); - const baseURL = dialog.locator('#create-endpoint-baseURL'); + await expect(drawer.getByRole('heading', { name: 'Create endpoint' })).toBeVisible(); + const baseURL = drawer.locator('#endpoints-custom-9999-baseURL'); await baseURL.fill('https://enter.example.com/v1'); await baseURL.press('Enter'); - await expect(dialog).toHaveCount(0); + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Enter Endpoint' }), + ).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click(); await expect(unsavedBar(page)).toHaveCount(1); await page.getByRole('button', { name: 'Discard' }).click(); }); }); -test.describe('Configuration: delete buttons', () => { - test('every delete button is announced as "Delete "', async ({ page }) => { +test.describe('Configuration: deleting a custom endpoint', () => { + test('a librechat.yaml endpoint offers no delete; an added one is deleted with Undo (D1, D9)', async ({ + page, + }) => { await openConfigTab(page, 'custom'); - const deleteButtons = page.locator('form button.icon-btn-danger'); - test.skip((await deleteButtons.count()) === 0, 'No custom endpoints to delete'); - await expect(page.locator('form button[aria-label="trash"]')).toHaveCount(0); - for (const label of await deleteButtons.evaluateAll((els) => - els.map((el) => el.getAttribute('aria-label') ?? ''), - )) { - expect(label).toMatch(/^Delete \S/); - } - const results = await new AxeBuilder({ page }) + const rows = page.locator('form li[data-endpoint-row]'); + await expect(rows.first()).toBeVisible(); + const names = await new AxeBuilder({ page }) .include('form') - .withRules(['button-name']) + .withRules(['button-name', 'list', 'listitem']) .analyze(); - expect(results.violations).toEqual([]); - }); + expect(names.violations).toEqual([]); + const yamlRow = rows.filter({ hasText: 'librechat.yaml' }).first(); + test.skip((await yamlRow.count()) === 0, 'No librechat.yaml endpoints'); + await yamlRow.locator('button[aria-haspopup="dialog"]').click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByRole('button', { name: 'Delete endpoint' })).toHaveCount(0); + await drawer.getByRole('button', { name: 'Done' }).click(); - test('keyboard Enter on a card trash button deletes the entry without toggling the card', async ({ - page, - }) => { - await openConfigTab(page, 'custom'); - const headers = page.locator('form [data-entry-card] button[aria-expanded]'); - const before = await headers.count(); - test.skip(before === 0, 'No custom endpoints to delete'); - const trashes = page.locator('form [data-entry-card] button[aria-label^="Delete "]'); - test.skip( - (await trashes.count()) === 0, - 'Every custom endpoint comes from librechat.yaml (D1)', - ); - const trash = trashes.first(); - await trash.focus(); - await page.keyboard.press('Enter'); - await expect(headers).toHaveCount(before - 1); - await expect(headers.first()).toHaveAttribute('aria-expanded', 'false'); + const added = rows.filter({ hasText: 'Added in admin' }).first(); + test.skip((await added.count()) === 0, 'Every custom endpoint comes from librechat.yaml (D1)'); + const name = (await added.getAttribute('data-endpoint-row')) ?? ''; + await added.locator('button[aria-haspopup="dialog"]').click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete endpoint' }).click(); + await expect(page.getByText('Will be deleted when you save')).toBeVisible(); await expect(unsavedBar(page)).toHaveCount(1); - await page.getByRole('button', { name: 'Discard' }).click(); + await page.getByRole('button', { name: `Undo deleting ${name}` }).click(); + await expect(unsavedBar(page)).toHaveCount(0); }); }); diff --git a/e2e/saves.spec.ts b/e2e/saves.spec.ts index be74cfa6..210cfeb6 100644 --- a/e2e/saves.spec.ts +++ b/e2e/saves.spec.ts @@ -17,15 +17,19 @@ async function openCustomEndpoints(page: Page) { await page.waitForLoadState('networkidle'); } -/** The expanded card of the custom endpoint named `name`. */ +/** The editor drawer of the custom endpoint named `name`. */ async function card(page: Page, name: string): Promise { - const entry = page.locator('[id^="section-endpoints-custom-"]').filter({ - has: page.locator('span.truncate').getByText(name, { exact: true }), - }); - await expect(entry).toHaveCount(1); - const header = entry.locator('button[aria-expanded]').first(); - if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click(); - return entry; + const row = page.locator(`form li[data-endpoint-row="${name}"]`); + await expect(row).toHaveCount(1); + await row.locator('button[aria-haspopup="dialog"]').click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByRole('heading', { name, exact: true })).toBeVisible(); + return drawer; +} + +async function closeEditor(page: Page) { + await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); } async function review(page: Page) { @@ -54,10 +58,13 @@ test.describe('Configuration saves: custom endpoints by identity', () => { page, }) => { await openCustomEndpoints(page); - const label = (await card(page, YAML_ENDPOINT)).getByLabel('Model display label'); + const label = (await card(page, YAML_ENDPOINT)).getByRole('textbox', { + name: 'Model display label', + }); const original = await label.inputValue(); const edited = `E2E ${Date.now()}`; await setText(label, edited); + await closeEditor(page); const dialog = await review(page); await expect(dialog).toContainText('Save 1 change to the base configuration?'); await expect(dialog).toContainText(YAML_ENDPOINT); @@ -67,18 +74,20 @@ test.describe('Configuration saves: custom endpoints by identity', () => { await page.reload(); await page.waitForLoadState('networkidle'); - const reloaded = (await card(page, YAML_ENDPOINT)).getByLabel('Model display label'); + const reloaded = (await card(page, YAML_ENDPOINT)).getByRole('textbox', { + name: 'Model display label', + }); await expect(reloaded).toHaveValue(edited); await setText(reloaded, original); + await closeEditor(page); await review(page); await saveAndWait(page); }); test('a librechat.yaml endpoint cannot be removed or renamed (D1)', async ({ page }) => { await openCustomEndpoints(page); - await expect(page.locator(`button[aria-label="Delete ${YAML_ENDPOINT}"]`)).toHaveCount(0); - const yamlCard = await card(page, YAML_ENDPOINT); + await expect(yamlCard.getByRole('button', { name: 'Delete endpoint' })).toHaveCount(0); await expect(yamlCard.getByRole('textbox', { name: /^Name/ })).toHaveCount(0); await expect(yamlCard.locator('[data-static-value][id$="-name"]')).toHaveText(YAML_ENDPOINT); }); @@ -86,33 +95,71 @@ test.describe('Configuration saves: custom endpoints by identity', () => { test('an endpoint added in the panel can be renamed and deleted', async ({ page }) => { const name = `E2E Endpoint ${Date.now()}`; await openCustomEndpoints(page); - await page - .getByRole('button', { name: /Create endpoint/ }) - .first() - .click(); + await page.getByRole('button', { name: 'Create endpoint' }).first().click(); const create = page.getByRole('dialog'); - await create.locator('#create-endpoint-name').fill(name); - await create.locator('#create-endpoint-apiKey').fill('user_provided'); - await create.locator('#create-endpoint-baseURL').fill('https://e2e.example.com/v1'); + await create.getByRole('button', { name: /^Blank endpoint/ }).click(); + await create.locator('#endpoints-custom-9999-name').fill(name); + await create.locator('#endpoints-custom-9999-apiKey').fill('user_provided'); + await create.locator('#endpoints-custom-9999-baseURL').fill('https://e2e.example.com/v1'); const model = create.getByLabel('Add models, comma-separated'); await model.fill('e2e-model'); await model.press('Enter'); - await create.getByRole('button', { name: /^Create$/ }).click(); + await create.getByRole('button', { name: 'Create endpoint', exact: true }).click(); + await closeEditor(page); await review(page); await saveAndWait(page); - await setText((await card(page, name)).getByLabel(/^Name\s*\*?$/), `${name} 2`); + const editor = await card(page, name); + await setText(editor.getByLabel(/^Name\s*\*?$/), `${name} 2`); + await closeEditor(page); const dialog = await review(page); await expect(dialog).toContainText(`${name} 2`); await saveAndWait(page); await page.reload(); await page.waitForLoadState('networkidle'); - await page.locator(`button[aria-label="Delete ${name} 2"]`).click(); + const renamed = await card(page, `${name} 2`); + await renamed.getByRole('button', { name: 'Delete endpoint' }).click(); await review(page); await saveAndWait(page); await page.reload(); await page.waitForLoadState('networkidle'); - await expect(page.locator(`button[aria-label="Delete ${name} 2"]`)).toHaveCount(0); + await expect(page.locator(`form li[data-endpoint-row="${name} 2"]`)).toHaveCount(0); + }); + + test('renaming an endpoint whose key is stored asks for the key again (CE-1)', async ({ + page, + }) => { + const name = `E2E Keyed ${Date.now()}`; + await openCustomEndpoints(page); + await page.getByRole('button', { name: 'Create endpoint' }).first().click(); + const create = page.getByRole('dialog'); + await create.getByRole('button', { name: /^Blank endpoint/ }).click(); + await create.locator('#endpoints-custom-9999-name').fill(name); + await create.locator('#endpoints-custom-9999-apiKey').fill('sk-e2e-literal-key-0000ABCD'); + await create.locator('#endpoints-custom-9999-baseURL').fill('https://e2e.example.com/v1'); + const model = create.getByLabel('Add models, comma-separated'); + await model.fill('e2e-model'); + await model.press('Enter'); + await create.getByRole('button', { name: 'Create endpoint', exact: true }).click(); + await closeEditor(page); + await review(page); + await saveAndWait(page); + + const editor = await card(page, name); + await setText(editor.getByLabel(/^Name\s*\*?$/), `${name} 2`); + await expect( + editor.getByRole('alert').filter({ hasText: /Enter the API key again/ }), + ).toBeVisible(); + await closeEditor(page); + const blocked = await review(page); + await expect(blocked.getByRole('button', { name: /^Save/ })).toBeDisabled(); + await blocked.getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Discard' }).click(); + + const cleanup = await card(page, name); + await cleanup.getByRole('button', { name: 'Delete endpoint' }).click(); + await review(page); + await saveAndWait(page); }); }); diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 743bc8b0..6a1f7bf6 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -37,6 +37,7 @@ import { getKeyedCollection, isConfigValueEqual, } from '@/utils'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext'; import { buildTocEntries, findTocEntry, toJumpTargets } from './toc'; import { RevealTargetContext } from '@/contexts/RevealTargetContext'; @@ -571,41 +572,43 @@ export function ConfigPage({ {isReady && ( - + + + )} diff --git a/src/components/configuration/fields/KeyValueField.tsx b/src/components/configuration/fields/KeyValueField.tsx index 3b9f1687..78cf7e9b 100644 --- a/src/components/configuration/fields/KeyValueField.tsx +++ b/src/components/configuration/fields/KeyValueField.tsx @@ -44,6 +44,7 @@ function KVInput({ disabled, type = 'text', title, + part, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, @@ -57,6 +58,8 @@ function KVInput({ disabled?: boolean; type?: 'text' | 'number'; title?: string; + /** Which half of the pair the input edits, as `data-kv-part` (e.g. so placeholders go into values only). */ + part: t.KVPart; 'aria-label'?: string; 'aria-invalid'?: boolean; 'aria-describedby'?: string; @@ -78,6 +81,7 @@ function KVInput({ aria-label={ariaLabel} aria-invalid={ariaInvalid || undefined} aria-describedby={ariaDescribedBy} + data-kv-part={part} className={className} /> ); @@ -254,6 +258,7 @@ export function KeyValueField({ } else { control = (
onChange(field.key, next), + onRevert: clearField, + disabled, + }); /** Writes one key of this field's object value, merging into its latest value. */ const changeChild: EntryChange = (childPath, childValue) => { @@ -515,6 +529,7 @@ function InlineFieldBody({ required={required} description={fieldDescription(field, meta, localize)} error={error} + extraChips={entryChips} control={ disabled ? ( @@ -571,7 +586,7 @@ function InlineFieldBody({ if (kind === 'array-object') { const renderFn = collectionRenderOverrides?.[field.key] ?? renderCollectionEntryFields; return ( - + + + {extraChips} {shadowedBy && } - {required && ( - - )} ) : undefined; return ( @@ -736,6 +756,8 @@ function InlineRow({ control={control} chips={chips} error={error} + required={required} + width={width} /> ); } @@ -744,23 +766,36 @@ function CollectionRow({ title, fieldId, required, + extraChips, children, }: { title: string; fieldId: string; required?: boolean; + /** Chips a collection editor adds (`EntryFieldChipsContext`). */ + extraChips?: ReactNode; children: ReactNode; }) { + const heading = ( + + {title} + {required && ( + + )} + + ); return ( -
- - {title} - {required && ( - - )} - +
+ {extraChips ? ( +
+ {heading} + {extraChips} +
+ ) : ( + heading + )} {children}
); diff --git a/src/components/configuration/sections/CreateCustomEndpointDialog.tsx b/src/components/configuration/sections/CreateCustomEndpointDialog.tsx deleted file mode 100644 index a24d0f58..00000000 --- a/src/components/configuration/sections/CreateCustomEndpointDialog.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Dialog for creating a new custom endpoint entry. - * - * Uses the same grouped field layout as expanded custom endpoint cards — - * both are driven by the `FIELD_GROUPS` config in endpoints/groups.ts via - * the `renderFields` prop injected from the parent. - */ - -import { useState, useCallback, useMemo } from 'react'; -import { EModelEndpoint } from 'librechat-data-provider'; -import type * as t from '@/types'; -import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext'; -import { validateLoadableEndpoint } from '@/utils'; -import { FormDialog } from '@/components/shared'; -import { labelForPath } from '../meta'; -import { useLocalize } from '@/hooks'; - -/** The draft as it would be added: empty values and empty lists left out. */ -function toEntry(draft: t.ConfigRecord): t.ConfigRecord { - const entry: t.ConfigRecord = {}; - for (const [key, val] of Object.entries(draft)) { - if (val === '' || val === undefined || val === null) continue; - if (Array.isArray(val) && val.length === 0) continue; - entry[key] = val; - } - return entry; -} - -const DRAFT_PATH = 'create-endpoint'; - -const RESERVED_NAMES: ReadonlySet = new Set(Object.values(EModelEndpoint)); - -/** Why a name can't be used: another entry has it (in any letter case), or LibreChat reserves it. */ -function nameProblem(name: string, existingNames: ReadonlySet): string | null { - if (name === '') return null; - if (RESERVED_NAMES.has(name)) return 'com_config_endpoint_name_reserved'; - const lower = name.toLowerCase(); - for (const existing of existingNames) { - if (existing.toLowerCase() === lower) return 'com_config_endpoint_name_exists'; - } - return null; -} - -/** - * Create is enabled once the endpoint has what LibreChat needs to load it (a - * unique, non-reserved name, an API key, a base URL and a default model - * list); until then the footer says what is still missing. - */ -export function CreateCustomEndpointDialog({ - open, - onClose, - onSave, - fields, - existingNames, - renderFields, -}: { - open: boolean; - onClose: () => void; - onSave: (entry: t.ConfigRecord) => void; - fields: t.SchemaField[]; - existingNames: ReadonlySet; - renderFields: t.CollectionRenderFields; -}) { - const localize = useLocalize(); - const [draft, setDraft] = useState({}); - - const handleFieldChange = useCallback((key: string, value: t.ConfigValue) => { - setDraft((prev) => ({ ...prev, [key]: value })); - }, []); - - const name = typeof draft.name === 'string' ? draft.name.trim() : ''; - const problem = nameProblem(name, existingNames); - const isDuplicate = problem != null; - const missing = useMemo(() => { - const labels = validateLoadableEndpoint(toEntry(draft), 0).map((issue) => - labelForPath(`endpoints.custom.0.${issue.entryPath}`, localize), - ); - return name ? labels : [localize('com_config_field_name'), ...labels]; - }, [draft, name, localize]); - - const problemText = problem ? localize(problem) : undefined; - /** The name problem is also shown at the Name field, where it is fixed. */ - const draftErrors = useMemo((): t.PathErrors => { - const errors: t.PathErrors = {}; - if (problemText) errors[`${DRAFT_PATH}.name`] = problemText; - return errors; - }, [problemText]); - - const handleSubmit = useCallback(() => { - if (!name || isDuplicate || missing.length > 0) return; - onSave({ ...toEntry(draft), name }); - setDraft({}); - onClose(); - }, [draft, name, isDuplicate, missing, onSave, onClose]); - - const handleClose = useCallback(() => { - setDraft({}); - onClose(); - }, [onClose]); - - return ( - 0} - saving={false} - error={problemText} - hint={ - missing.length > 0 - ? localize('com_config_endpoint_required_missing', { fields: missing.join(', ') }) - : undefined - } - size="lg" - onSubmit={handleSubmit} - onClose={handleClose} - > - - {renderFields(fields, draft, DRAFT_PATH, handleFieldChange)} - - - ); -} diff --git a/src/components/configuration/sections/endpoints/Banner.tsx b/src/components/configuration/sections/endpoints/Banner.tsx new file mode 100644 index 00000000..a0619a00 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Banner.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { DecorativeIcon } from '@/components/shared'; +import { cn } from '@/utils'; + +const TONE_CLASSES: Record = { + info: 'border-(--settings-info-border) bg-(--settings-info-bg) text-(--settings-info-fg)', + warning: + 'border-(--settings-warn-mark) bg-(--settings-warn-bg) text-(--settings-warn-fg) border-dashed', + danger: 'border-(--settings-danger-mark) bg-(--settings-danger-bg) text-(--settings-danger-fg)', + neutral: + 'border-(--cui-color-stroke-default) bg-(--settings-neutral-bg) text-(--cui-color-text-default)', +}; + +const TONE_ICONS: Record = { + info: 'info-in-circle', + warning: 'warning', + danger: 'warning', + neutral: 'lock', +}; + +/** A short notice in the endpoint editor: where an endpoint comes from, a warning, an action. */ +export function Banner({ + tone, + children, + action, + role, +}: { + tone: t.EndpointBannerTone; + children: ReactNode; + action?: ReactNode; + role?: 'status' | 'alert'; +}) { + return ( +
+ + + +
{children}
+ {action &&
{action}
} +
+ ); +} diff --git a/src/components/configuration/sections/endpoints/Chips.tsx b/src/components/configuration/sections/endpoints/Chips.tsx new file mode 100644 index 00000000..262b79ec --- /dev/null +++ b/src/components/configuration/sections/endpoints/Chips.tsx @@ -0,0 +1,84 @@ +import type * as t from '@/types'; +import { useLocalize, useFieldState, useValuesScope, useRevertLabels } from '@/hooks'; +import { layerRevertTarget, lowerLayerValue } from '../../renderer/defaults'; +import { DecorativeIcon, StateChip } from '@/components/shared'; +import { isConfigRecord, isConfigValueEqual } from '@/utils'; +import { sourceLabel } from '../../values'; + +/** Unset, empty text and empty lists or objects all read as "not set" when comparing with the saved value. */ +function normalize(value: t.ConfigValue): t.ConfigValue { + if (value === null || value === '') return undefined; + if (Array.isArray(value) && value.length === 0) return undefined; + if (isConfigRecord(value) && Object.keys(value).length === 0) return undefined; + return value; +} + +/** + * Chips of one endpoint field (WS10 CE-2): "Edited" with Undo while the field + * differs from its saved value; otherwise, for an endpoint the layer beneath + * defines, when the edit target overrides one of its values, "Admin + * override" (or the profile) with "Revert to librechat.yaml (…)" / "Remove + * override (inherits …)". Values the endpoint simply inherits, and endpoints + * the target added, carry no such chip: the drawer header names the source. + */ +export function FieldChips({ + path, + label, + value, + saved, + showOverride, + onChange, + onRevert, + disabled, +}: t.FieldChipsProps) { + const localize = useLocalize(); + const state = useFieldState(path); + const scope = useValuesScope(); + const revert = useRevertLabels(path, label); + if (disabled) return null; + const handleRevert = (): void => { + if (onRevert) { + onRevert(); + return; + } + const target = state.isOverride + ? layerRevertTarget(state.revertTarget) + : lowerLayerValue(state); + onChange(target?.value); + }; + + if (!isConfigValueEqual(normalize(value), normalize(saved))) { + return ( + onChange(saved)} + undoLabel={localize('com_config_ui_undo_change', { name: label })} + /> + ); + } + if (!showOverride || !state.isOverride) return null; + return ( + <> + + {revert && ( + + )} + + ); +} diff --git a/src/components/configuration/sections/endpoints/Create.tsx b/src/components/configuration/sections/endpoints/Create.tsx new file mode 100644 index 00000000..b7822d57 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Create.tsx @@ -0,0 +1,317 @@ +import { useMemo, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { BLANK_ENDPOINT_TEMPLATE, ENDPOINT_TEMPLATES } from '@/constants'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { isConfigRecord, isConfigValueEqual } from '@/utils'; +import { hasBlockingIssue, validateEndpoint } from './model'; +import { useLocalize, useValuesScope } from '@/hooks'; +import { sourceLabel } from '../../values'; +import { EndpointDrawer } from './Drawer'; +import { EndpointEditor } from './Editor'; +import { labelForPath } from '../../meta'; +import { describeIssue } from './issues'; +import { Banner } from './Banner'; + +/** + * Data path the draft is edited under: an index no list reaches, so labels, + * help and controls resolve like a real entry's (`endpoints.custom[]`) while + * the draft stays apart from the page's values. + */ +const DRAFT_PATH = 'endpoints.custom.9999'; + +/** Problems that only say something is still missing: listed in the footer, flagged at the field once Create is tried. */ +const MISSING_CODES: ReadonlySet = new Set([ + 'nameRequired', + 'apiKeyRequired', + 'baseUrlRequired', + 'modelsRequired', +]); + +/** The draft as it would be added: empty values and empty lists left out, the name trimmed. */ +function toEntry(draft: t.ConfigRecord): t.ConfigRecord { + const entry: t.ConfigRecord = {}; + for (const [key, value] of Object.entries(draft)) { + if (value === '' || value === undefined || value === null) continue; + if (Array.isArray(value) && value.length === 0) continue; + if (isConfigRecord(value) && Object.keys(value).length === 0) continue; + entry[key] = value; + } + if (typeof entry.name === 'string') entry.name = entry.name.trim(); + return entry; +} + +/** The template's name, or "Name 2", "Name 3"… when one is taken. */ +function freeName(name: string, taken: readonly string[]): string { + if (name === '') return name; + const lower = new Set(taken.map((other) => other.toLowerCase())); + if (!lower.has(name.toLowerCase())) return name; + let n = 2; + while (lower.has(`${name} ${n}`.toLowerCase())) n++; + return `${name} ${n}`; +} + +/** + * Creating a custom endpoint (WS10 CE-3, CE-10): pick a provider template or + * start blank, fill in what LibreChat needs to serve it (name, base URL, API + * key, at least one model), then Create adds it to the list as a pending + * change and opens it in the editor. Create stays disabled until the endpoint + * is complete; a filled-in draft asks before it is discarded. In a profile, a + * name Base already uses is redirected to overriding that endpoint (CE-8). + */ +export function EndpointCreator({ + open, + fields, + names, + inheritedNames, + onCreate, + onOverride, + onClose, + returnFocus, +}: t.EndpointCreatorProps) { + const localize = useLocalize(); + const scope = useValuesScope(); + const [template, setTemplate] = useState(null); + const [draft, setDraft] = useState({}); + const [attempted, setAttempted] = useState(false); + const [confirming, setConfirming] = useState(false); + + const reset = () => { + setTemplate(null); + setDraft({}); + setAttempted(false); + setConfirming(false); + }; + const close = () => { + reset(); + onClose(); + }; + + const start = (choice: t.EndpointTemplate) => { + const entry = { ...choice.entry }; + if (typeof entry.name === 'string') entry.name = freeName(entry.name, names); + setTemplate(choice); + setDraft(entry); + }; + + const entry = useMemo(() => toEntry(draft), [draft]); + const issues = useMemo( + () => + validateEndpoint({ + entry: draft, + otherNames: names, + fromLowerLayer: false, + storedSecret: false, + }), + [draft, names], + ); + const shownIssues = attempted ? issues : issues.filter((issue) => !MISSING_CODES.has(issue.code)); + const missing = issues + .filter((issue) => MISSING_CODES.has(issue.code)) + .map((issue) => labelForPath(`${DRAFT_PATH}.${issue.field}`, localize)); + const name = typeof entry.name === 'string' ? entry.name : ''; + const inherited = scope != null && inheritedNames.includes(name) ? name : null; + const blocked = hasBlockingIssue(issues) || inherited != null; + const isDirty = template != null && !isConfigValueEqual(draft, template.entry); + + const requestClose = () => { + if (isDirty && !confirming) { + setConfirming(true); + return; + } + close(); + }; + + const submit = () => { + if (blocked) { + setAttempted(true); + return; + } + onCreate(entry); + reset(); + }; + + const handleFieldChange = (key: string, value: t.ConfigValue) => { + setConfirming(false); + setDraft((prev) => { + const next = { ...prev, [key]: value }; + if (value === undefined) delete next[key]; + return next; + }); + }; + + const footer = confirming ? ( +
+ + {localize('com_config_ce_discard_confirm')} + +
+
+
+ ) : ( +
+ + {template && missing.length > 0 + ? localize('com_config_endpoint_required_missing', { fields: missing.join(', ') }) + : null} + +
+ {template && ( +
+
+ ); + + return ( + { + reset(); + onOverride(inherited); + }} + /> + } + > + {localize('com_config_ce_create_inherited', { + name: inherited, + scope: scope ? sourceLabel('scope', localize, scope) : '', + })} + + ) : undefined + } + footer={footer} + > + {template ? ( +
{ + event.preventDefault(); + submit(); + }} + className="flex flex-col gap-4" + > + + + +

+ {localize('com_config_ce_create_more')} +

+ {attempted && blocked && ( +

+ {issues + .filter((issue) => issue.severity === 'error') + .map((issue) => describeIssue(issue, localize)) + .join(' ')} +

+ )} +
+ + ))} + +
+ ); +} diff --git a/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx b/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx index 9b38b087..102729c9 100644 --- a/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx +++ b/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx @@ -1,146 +1,395 @@ -import { useMemo, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import type * as t from '@/types'; -import { CreateCustomEndpointDialog } from '../CreateCustomEndpointDialog'; -import { ArrayObjectField } from '../../fields/ArrayObjectField'; -import { renderParamDefinitionFields } from './ParamDefinitions'; -import { useLocalize, useLowerEntryKeys } from '@/hooks'; -import { GroupedFieldRenderer } from './GroupedFields'; -import { DecorativeIcon } from '@/components/shared'; -import { isConfigRecord } from '@/utils'; - -/** Collection render overrides for custom endpoint nested fields. */ -const COLLECTION_RENDER_OVERRIDES: Record = { - paramDefinitions: renderParamDefinitionFields, -}; +import { EndpointList, SourceBadge, StatusBadge, Badge, isDeletable } from './List'; +import { useLocalize, useValuesScope, useEndpointRows } from '@/hooks'; +import { RevealTargetContext } from '@/contexts/RevealTargetContext'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { isConfigRecord, isRowVisible } from '@/utils'; +import { SearchInput } from '@/components/shared'; +import { revealConfigPath } from '../../reveal'; +import { sourceLabel } from '../../values'; +import { EndpointCreator } from './Create'; +import { EndpointDrawer } from './Drawer'; +import { EndpointEditor } from './Editor'; +import { modelNames } from './model'; +import { Banner } from './Banner'; -/** - * Custom `renderFields` function for custom endpoint entries. - * Renders fields in semantic groups instead of a flat list. - * Matches the `CollectionRenderFields` signature so it can be injected - * into `ArrayObjectField` and `ObjectEntryCard`. - */ -const LOCKED_NAME: ReadonlySet = new Set(['name']); +/** Shown once the list is long enough that scanning it is slower than typing. */ +const FILTER_THRESHOLD = 6; -/** - * `lockedNames`: endpoints the layer beneath defines (D1), whose name is - * shown read-only; a name two entries share is left editable so the - * duplicate can be fixed. - */ -function makeGroupedEndpointFields( - disabled: boolean | undefined, - lockedNames: ReadonlySet, -): t.CollectionRenderFields { - return (fields, parentValue, parentPath, onChange, _addFieldTriggerRef, editSessionId) => ( - - ); +function matchesFilter(row: t.EndpointRow, query: string): boolean { + if (query === '') return true; + const haystack = [row.title, row.host ?? '', ...modelNames(row.value)].join('\n').toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((word) => haystack.includes(word)); } -const NAME_SEPARATOR = '\u0000'; - -/** A draft in the create dialog is a new entry: nothing in it is locked, so a taken name can be corrected. */ -const NO_LOCKED_NAMES: ReadonlySet = new Set(); - -/** Names the layer beneath defines that exactly one current entry carries, joined (a stable memo key). */ -function uniqueLowerNames(items: t.ConfigValue[], lowerNames: ReadonlySet): string { - const counts = new Map(); - for (const item of items) { - if (!isConfigRecord(item) || typeof item.name !== 'string') continue; - counts.set(item.name, (counts.get(item.name) ?? 0) + 1); +/** The first field inside an entry the tab filter matched (`baseURL`), to open the editor at. */ +function filterMatchIn(filter: t.PathIndex | null, entryPath: string): string | undefined { + if (!filter) return undefined; + const prefix = `${entryPath}.`; + for (const path of filter.paths) { + if (path.startsWith(prefix)) return path.slice(prefix.length); } - return [...lowerNames].filter((name) => counts.get(name) === 1).join(NAME_SEPARATOR); + return undefined; } +/** `endpoints.custom.3.baseURL` → index 3 and field `baseURL`, for a path inside the list. */ +function locateEntry(path: string, listPath: string): { index: number; field?: string } | null { + if (!path.startsWith(`${listPath}.`)) return null; + const [segment, ...rest] = path.slice(listPath.length + 1).split('.'); + if (!/^\d+$/.test(segment)) return null; + return { index: Number(segment), field: rest.length > 0 ? rest.join('.') : undefined }; +} + +/** + * Custom endpoints tab (WS10): a compact, filterable list of the endpoints + * with where each comes from and whether it is served, and an editor drawer + * per endpoint. Edits are staged on the page like every other setting and + * saved per endpoint by name. Endpoints librechat.yaml (or, in a profile, + * Base) defines can be overridden but not renamed or deleted (D1); in a + * profile they open read-only until "Override for " (CE-8). + */ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { const { fields, parentPath, parentValue, getValue, onChange, disabled, editSessionId } = props; const localize = useLocalize(); - const [createOpen, setCreateOpen] = useState(false); - const lowerNames = useLowerEntryKeys(`${parentPath}.custom`, 'name'); - const customField = fields.find((f) => f.key === 'custom'); + const scope = useValuesScope(); + const rowFilter = useContext(RowFilterContext); + const revealTarget = useContext(RevealTargetContext); const path = `${parentPath}.custom`; - const parentObj = - parentValue && typeof parentValue === 'object' && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; - const value = getValue(path, parentObj.custom ?? []); - const items = Array.isArray(value) ? value : []; - const lockedNames = uniqueLowerNames(items, lowerNames); - const renderGroupedEndpointFields = useMemo( - () => - makeGroupedEndpointFields( - disabled, - lockedNames ? new Set(lockedNames.split(NAME_SEPARATOR)) : NO_LOCKED_NAMES, - ), - [disabled, lockedNames], - ); - const renderDraftFields = useMemo( - () => makeGroupedEndpointFields(disabled, NO_LOCKED_NAMES), - [disabled], + const customField = fields.find((field) => field.key === 'custom'); + const parentObj = isConfigRecord(parentValue) ? parentValue : {}; + const listValue = getValue(path, parentObj.custom ?? []); + const items = useMemo(() => (Array.isArray(listValue) ? listValue : []), [listValue]); + const { rows, removals, lowerEntry } = useEndpointRows({ path, items }); + const [query, setQuery] = useState(''); + const [openKey, setOpenKey] = useState(null); + const [creating, setCreating] = useState(false); + const [overriding, setOverriding] = useState>(() => new Set()); + const [revealField, setRevealField] = useState(); + const [openAfterCreate, setOpenAfterCreate] = useState(null); + const bodyRef = useRef(null); + const latestRef = useRef({}); + const lastRowIdRef = useRef(''); + const createButtonRef = useRef(null); + const idPrefix = `section-${parentPath}-custom`; + const scopeName = scope ? sourceLabel('scope', localize, scope) : ''; + + const names = useMemo(() => rows.flatMap((row) => (row.identity ? [row.identity] : [])), [rows]); + const modelsOf = useMemo(() => { + const byName = new Map(rows.map((row) => [row.identity ?? '', modelNames(row.value)])); + return (endpoint: string): readonly string[] => byName.get(endpoint) ?? []; + }, [rows]); + const inheritedNames = useMemo( + () => rows.filter((row) => row.origin === 'inherited').map((row) => row.loadedName ?? ''), + [rows], ); + useEffect(() => { + setOverriding(new Set()); + }, [editSessionId]); + + const rowsRef = useRef(rows); + rowsRef.current = rows; + useEffect(() => { + if (!revealTarget) return; + const located = locateEntry(revealTarget, path); + const row = located ? rowsRef.current[located.index] : undefined; + if (!located || !row) return; + setCreating(false); + setOpenKey(row.key); + setRevealField(located.field); + }, [revealTarget, path]); + + useEffect(() => { + if (!openAfterCreate) return; + const row = rows.find((candidate) => candidate.isNew && candidate.identity === openAfterCreate); + if (!row) return; + setOpenAfterCreate(null); + setOpenKey(row.key); + }, [openAfterCreate, rows]); + if (!customField) return null; + const entryFields = customField.children ?? []; + const open = openKey ? rows.find((row) => row.key === openKey) : undefined; + if (open) { + latestRef.current = open.value; + lastRowIdRef.current = `${idPrefix}-${open.index}`; + } + const rowButton = (): HTMLElement | null => + document.querySelector( + `#${CSS.escape(lastRowIdRef.current)} button[aria-haspopup="dialog"]`, + ); + + const configuredOnly = Boolean(props.showConfiguredOnly) && scope != null; + const trimmedQuery = query.trim(); + const visible = rows.filter( + (row) => + isRowVisible(rowFilter, `${path}.${row.index}`) && + matchesFilter(row, trimmedQuery) && + (!configuredOnly || row.overridden || row.edited || row.origin === 'scope'), + ); + const shownRemovals = trimmedQuery === '' ? removals : []; + + const closeDrawer = () => { + setOpenKey(null); + setRevealField(undefined); + }; + + const handleFieldChange = (key: string, value: t.ConfigValue) => { + if (!open) return; + const next = { ...latestRef.current, [key]: value }; + if (value === undefined) delete next[key]; + latestRef.current = next; + onChange(`${path}.${open.index}`, next); + }; + + const handleDelete = (row: t.EndpointRow) => { + if (!isDeletable(row)) return; + if (row.key === openKey) closeDrawer(); + onChange( + path, + items.filter((_, index) => index !== row.index), + ); + }; + + /** Back to what the layer beneath defines: the target's override of the endpoint is dropped on save. */ + const handleRevert = (row: t.EndpointRow) => { + const lower = row.loadedName ? lowerEntry(row.loadedName) : undefined; + if (!lower) return; + onChange(`${path}.${row.index}`, lower); + }; + + const handleUndoRemoval = (removal: t.EndpointRemoval) => { + const at = Math.min(removal.restoreIndex, items.length); + onChange(path, [...items.slice(0, at), removal.value, ...items.slice(at)]); + }; const handleCreate = (entry: t.ConfigRecord) => { onChange(path, [...items, entry]); + setCreating(false); + setOpenAfterCreate(typeof entry.name === 'string' ? entry.name : null); + }; + + const handleOverrideInstead = (name: string) => { + setCreating(false); + const row = rows.find((candidate) => candidate.loadedName === name); + if (!row) return; + setOverriding((prev) => new Set(prev).add(row.key)); + setOpenKey(row.key); }; - const existingNames = new Set( - items.flatMap((item) => - isConfigRecord(item) && typeof item.name === 'string' ? [item.name.trim()] : [], - ), + + const readOnlyInherited = + open != null && open.origin === 'inherited' && !open.overridden && !overriding.has(open.key); + const editable = open != null && !disabled && !readOnlyInherited; + + const drawerNotice = (row: t.EndpointRow) => { + if (row.origin === 'yaml') { + return ( + handleRevert(row)} + /> + ) : undefined + } + > + {localize( + row.overridden ? 'com_config_ce_notice_yaml_overridden' : 'com_config_ce_notice_yaml', + )} + + ); + } + if (row.origin !== 'inherited') return undefined; + if (readOnlyInherited) { + return ( + setOverriding((prev) => new Set(prev).add(row.key))} + /> + ) + } + > + {localize('com_config_ce_notice_inherited', { scope: scopeName })} + + ); + } + return ( + handleRevert(row)} + /> + ) : undefined + } + > + {localize('com_config_ce_notice_inherited_override', { scope: scopeName })} + + ); + }; + + const drawerFooter = (row: t.EndpointRow) => ( + <> + {!disabled && isDeletable(row) && ( + -
- )} - {disabled && isEmpty ? ( -
- {localize('com_config_no_custom_endpoints')} -
+
+
+ {rows.length >= FILTER_THRESHOLD && ( + + )} + {!disabled && ( +
+
+ )} +
+ {isEmpty ? ( +

+ {localize(scope ? 'com_config_ce_empty_scope' : 'com_config_no_custom_endpoints')} +

) : ( - onChange(path, v)} - onEntryChange={(index, v) => onChange(`${path}.${index}`, v)} - disabled={disabled} - hideAddButton - renderFields={renderGroupedEndpointFields} - entryIdPrefix={`section-${path.split('.')[0]}-custom`} - isEntryLocked={(item) => isConfigRecord(item) && lowerNames.has(String(item.name))} - editSessionId={editSessionId} - /> + <> + { + setRevealField(filterMatchIn(rowFilter, `${path}.${row.index}`)); + setOpenKey(row.key); + }} + onReveal={(row) => { + setCreating(false); + setOpenKey(row.key); + }} + onDelete={handleDelete} + onRevert={handleRevert} + onUndoRemoval={handleUndoRemoval} + idPrefix={idPrefix} + /> + {visible.length === 0 && ( +

+ {configuredOnly && trimmedQuery === '' + ? localize('com_config_ce_none_set_in_scope', { scope: scopeName }) + : localize('com_config_ce_filter_empty', { query: trimmedQuery })} +

+ )} + )} - setCreateOpen(false)} - onSave={handleCreate} - fields={customField.children ?? []} - existingNames={existingNames} - renderFields={renderDraftFields} + + + {open.isNew && ( + + )} + + + ) : undefined + } + subtitle={open?.host ?? undefined} + notice={open ? drawerNotice(open) : undefined} + footer={open ? drawerFooter(open) : null} + bodyRef={bodyRef} + returnFocus={rowButton} + > + {open && ( + { + const body = bodyRef.current; + if (body) revealConfigPath(body, { path: fieldPath }, { align: 'center' }); + }} + editSessionId={editSessionId} + /> + )} + + setCreating(false)} + returnFocus={() => createButtonRef.current?.querySelector('button') ?? null} />
); diff --git a/src/components/configuration/sections/endpoints/Drawer.tsx b/src/components/configuration/sections/endpoints/Drawer.tsx new file mode 100644 index 00000000..dec2e6ee --- /dev/null +++ b/src/components/configuration/sections/endpoints/Drawer.tsx @@ -0,0 +1,92 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import type * as t from '@/types'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { CloseButton } from '@/components/shared'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +/** + * Side drawer the custom endpoint editor opens in (WS10 CE-7), so the list + * stays in view behind it. Edits are staged on the page as they are made; + * closing never discards them. While creating, a close request goes to + * `onRequestClose` so a filled-in draft can be confirmed first. Closing + * returns focus to `returnFocus()` (the endpoint's row, or Create). + */ +export function EndpointDrawer({ + open, + onClose, + onRequestClose, + title, + badges, + subtitle, + notice, + footer, + children, + bodyRef, + returnFocus, +}: t.EndpointDrawerProps) { + const localize = useLocalize(); + const requestClose = onRequestClose ?? onClose; + + return ( + { + if (!isOpen) requestClose(); + }} + > + + + { + const target = returnFocus?.(); + if (!target) return; + event.preventDefault(); + target.focus(); + }} + className={cn( + 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl', + 'border-l border-(--cui-color-stroke-default) sm:w-[min(56rem,calc(100vw-3rem))]', + 'will-change-transform', + 'data-[state=closed]:animate-drawer-out data-[state=open]:animate-drawer-in', + )} + > +
+
+
+ + {title} + + {badges} +
+ +
+ {subtitle ?? localize('com_config_ce_drawer_desc')} +
+
+
+ +
+ {notice &&
{notice}
} + +
+ {children} +
+
+
+ {footer} +
+
+
+
+ ); +} diff --git a/src/components/configuration/sections/endpoints/Editor.tsx b/src/components/configuration/sections/endpoints/Editor.tsx new file mode 100644 index 00000000..c584c912 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Editor.tsx @@ -0,0 +1,478 @@ +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + ENDPOINT_SECTIONS, + PLACED_TOP_KEYS, + sectionOfField, + HIDDEN_ENDPOINT_KEYS, + MODEL_REFERENCE_KEYS, + ESSENTIAL_SECTION_IDS, + ENDPOINT_REFERENCE_KEYS, +} from './groups'; +import { + NameRow, + ParamSetRow, + ModelRefRow, + HiddenKeyRow, + DropParamsRow, + EndpointRefRow, +} from './Fields'; +import { ConfigErrorsProvider, ConfigErrorsContext } from '@/contexts/ConfigErrorsContext'; +import { EntryFieldChipsContext } from '@/contexts/EntryFieldChipsContext'; +import { getEntryKey, getValueAtPath, isConfigRecord } from '@/utils'; +import { renderInlineField, toFieldId } from '../../renderer'; +import { ParamDefinitionsRow } from './ParamDefinitions'; +import { UnknownSettings } from '../../UnknownSettings'; +import { HeaderPlaceholders } from './Request'; +import { SelectField } from '../../fields'; +import { labelForPath } from '../../meta'; +import { describeIssue } from './issues'; +import { PricingRow } from './Pricing'; +import { useLocalize } from '@/hooks'; +import { FieldChips } from './Chips'; +import { modelNames } from './model'; +import { Banner } from './Banner'; + +/** Fields an endpoint added in the panel can't be served without (CE-3). */ +const REQUIRED_KEYS: ReadonlySet = new Set(['name', 'apiKey', 'baseURL', 'models.default']); + +/** Required fields whose control says itself that it is empty (the model list). */ +const SELF_REPORTED: ReadonlySet = new Set(['modelsRequired']); + +/** For each model picker, the endpoint settings that say whose models it names. */ +const MODEL_ENDPOINT_KEYS: Readonly> = { + titleModel: ['titleEndpoint'], + activityModel: ['activityEndpoint', 'titleEndpoint'], + activityPhaseModel: ['activityPhaseEndpoint', 'activityEndpoint', 'titleEndpoint'], + reasoningLabelModel: ['reasoningLabelEndpoint', 'activityEndpoint', 'titleEndpoint'], +}; + +function asRecord(value: t.ConfigValue): t.ConfigRecord { + return isConfigRecord(value) ? value : {}; +} + +function hasValue(value: t.ConfigValue): boolean { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + if (isConfigRecord(value)) return Object.keys(value).length > 0; + return true; +} + +interface ResolvedField { + key: string; + field: t.SchemaField; + parentKey?: string; +} + +interface SectionLayout { + section: t.EndpointSectionDef; + visible: t.EndpointFieldDef[]; + hidden: t.EndpointFieldDef[]; + warnings: string[]; +} + +/** Schema fields by entry key, nested ones as `models.default`. */ +function indexFields(fields: t.SchemaField[]): Map { + const index = new Map(); + for (const field of fields) { + index.set(field.key, { key: field.key, field }); + const isGroup = field.children?.length && !field.isArray && field.type !== 'record'; + if (!isGroup) continue; + for (const child of field.children ?? []) { + const key = `${field.key}.${child.key}`; + index.set(key, { key, field: child, parentKey: field.key }); + } + } + return index; +} + +/** Schema keys no section places (newer LibreChat settings), for "Other settings". */ +function unplacedKeys(index: Map): t.EndpointFieldDef[] { + const placed = new Set(ENDPOINT_SECTIONS.flatMap((section) => section.fields.map((f) => f.key))); + const parents = new Set([...index.values()].flatMap((entry) => entry.parentKey ?? [])); + const defs: t.EndpointFieldDef[] = []; + for (const [key, resolved] of index) { + if (placed.has(key) || HIDDEN_ENDPOINT_KEYS.has(key) || parents.has(key)) continue; + const top = resolved.parentKey ?? key; + if (resolved.parentKey === undefined && PLACED_TOP_KEYS.has(key)) continue; + if (resolved.parentKey !== undefined && !PLACED_TOP_KEYS.has(top)) continue; + defs.push({ key, optional: true }); + } + return defs; +} + +/** The endpoint a model setting names models of: the first endpoint setting that is set, else this one. */ +function modelEndpoint(key: string, entry: t.ConfigRecord, own: string): string { + for (const endpointKey of MODEL_ENDPOINT_KEYS[key] ?? []) { + const named = entry[endpointKey]; + if (typeof named === 'string' && named !== '') return named; + } + return own; +} + +/** Keys of the parameter definitions an endpoint defines. */ +function definitionKeys(entry: t.ConfigRecord | undefined): ReadonlySet { + const list = asRecord(entry?.customParams).paramDefinitions; + if (!Array.isArray(list)) return new Set(); + return new Set(list.flatMap((item) => getEntryKey(item, 'key') ?? [])); +} + +/** The parameter set users of the endpoint get: `defaultParamsEndpoint`, else its native provider's. */ +function paramSetOf(entry: t.ConfigRecord): string { + const chosen = asRecord(entry.customParams).defaultParamsEndpoint; + if (typeof chosen === 'string' && chosen !== '' && chosen !== 'custom') return chosen; + return typeof entry.provider === 'string' ? entry.provider : 'custom'; +} + +/** + * The custom endpoint editor (WS10 CE-7): Connection, Models, Request + * shaping, Behaviour, Labels & reasoning, Parameters and Token pricing. + * Optional settings show only when set or added from "Add setting". Problems + * show at their field (errors) or atop their section (warnings), and every + * value the target overrides offers its revert (CE-2). + */ +export function EndpointEditor({ + path, + fields, + value, + onFieldChange, + issues, + origin, + disabled, + endpointNames, + storedSecret, + lowerEntry, + savedEntry, + modelsOf, + essentialsOnly, + showChips, + revealField, + onRevealed, + editSessionId, +}: t.EndpointEditorProps) { + const localize = useLocalize(); + const pageErrors = useContext(ConfigErrorsContext); + const index = useMemo(() => indexFields(fields), [fields]); + const latestRef = useRef(value); + latestRef.current = value; + const onRevealedRef = useRef(onRevealed); + onRevealedRef.current = onRevealed; + const [shown, setShown] = useState>(() => new Set()); + const [focusKey, setFocusKey] = useState(null); + const fromLower = origin === 'yaml' || origin === 'inherited'; + const name = typeof value.name === 'string' ? value.name : ''; + + /** Inline errors: this editor's own wording first, the save pipeline's for anything else. */ + const errors = useMemo((): t.PathErrors => { + const merged: t.PathErrors = { ...pageErrors }; + const own = new Set(); + for (const issue of issues) { + if (issue.severity !== 'error') continue; + const issuePath = `${path}.${issue.field}`; + if (!fromLower && SELF_REPORTED.has(issue.code)) { + delete merged[issuePath]; + continue; + } + if (own.has(issuePath)) continue; + own.add(issuePath); + merged[issuePath] = describeIssue(issue, localize); + } + return merged; + }, [issues, path, localize, pageErrors, fromLower]); + + const renderChips = useMemo((): t.EntryFieldChipsRenderer | null => { + if (!showChips || disabled) return null; + const prefix = `${path}.`; + return (props) => ( + + ); + }, [showChips, disabled, path, savedEntry, fromLower]); + + useEffect(() => { + if (!revealField) return; + const key = [...index.keys()].find( + (candidate) => revealField === candidate || revealField.startsWith(`${candidate}.`), + ); + if (key) setShown((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + const frame = requestAnimationFrame(() => onRevealedRef.current?.(`${path}.${revealField}`)); + return () => cancelAnimationFrame(frame); + }, [revealField, index, path]); + + useEffect(() => { + if (!focusKey) return; + setFocusKey(null); + requestAnimationFrame(() => { + const target = document.getElementById(toFieldId(`${path}.${focusKey}`)); + const control = target?.matches('input, select, textarea, button') + ? target + : target?.querySelector('input, select, textarea, button'); + control?.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + control?.focus({ preventScroll: true }); + }); + }, [focusKey, path]); + + const nestedChange = + (parentKey: string) => + (childPath: string, childValue: t.ConfigValue): void => { + const leaf = childPath.split('.').pop() ?? childPath; + const next = { ...asRecord(latestRef.current[parentKey]), [leaf]: childValue }; + if (childValue === undefined) delete next[leaf]; + latestRef.current = { ...latestRef.current, [parentKey]: next }; + onFieldChange(parentKey, Object.keys(next).length > 0 ? next : undefined); + }; + + const topChange = (key: string, next: t.ConfigValue): void => { + latestRef.current = { ...latestRef.current, [key]: next }; + onFieldChange(key, next); + }; + + const renderField = (key: string): ReactNode => { + const resolved = index.get(key); + if (!resolved) return null; + const { field, parentKey } = resolved; + const parentValue = parentKey ? asRecord(value[parentKey]) : value; + const parentPath = parentKey ? `${path}.${parentKey}` : path; + const change = parentKey ? nestedChange(parentKey) : topChange; + const dataPath = `${parentPath}.${field.key}`; + const current = parentValue[field.key]; + const write = (next: t.ConfigValue) => change(field.key, next); + const chips = + key === 'name' && fromLower + ? undefined + : (renderChips?.({ + path: dataPath, + label: labelForPath(dataPath, localize), + value: current, + onChange: write, + }) ?? undefined); + const required = !fromLower && REQUIRED_KEYS.has(key); + const common = { path: dataPath, field, value: current, chips, disabled, onChange: write }; + + if (key === 'name') { + const warning = issues.find( + (issue) => issue.field === 'name' && issue.severity === 'warning', + ); + return ( + + ); + } + if (key === 'apiKey' && fromLower && !storedSecret && lowerEntry?.apiKey === undefined) { + return ; + } + if (ENDPOINT_REFERENCE_KEYS.has(key)) { + const others = endpointNames.filter((other) => other !== name); + return ; + } + if (key === 'customParams.defaultParamsEndpoint') { + return ; + } + if (key === 'customParams.paramDefinitions') { + return ( + + ); + } + if (MODEL_REFERENCE_KEYS.has(key)) { + const endpoint = modelEndpoint(key, value, name); + const models = endpoint === name ? modelNames(value) : (modelsOf?.(endpoint) ?? []); + return ; + } + if (key === 'tokenConfig') { + return ( + + ); + } + if (key === 'dropParams') { + return ; + } + const shownField = required ? { ...field, isOptional: false } : field; + if (key === 'headers' && !disabled) { + return ( + + {renderInlineField(shownField, parentValue, parentPath, change, localize, disabled)} + + ); + } + return renderInlineField( + shownField, + parentValue, + parentPath, + change, + localize, + disabled, + undefined, + required, + editSessionId, + ); + }; + + const isShown = (def: t.EndpointFieldDef): boolean => { + if (!def.optional || shown.has(def.key)) return true; + const [head, child] = def.key.split('.'); + return hasValue(child === undefined ? value[head] : asRecord(value[head])[child]); + }; + + const warnings = new Map(); + for (const issue of issues) { + if (issue.severity !== 'warning' || issue.field === 'name') continue; + const section = sectionOfField(issue.field); + warnings.set(section, [...(warnings.get(section) ?? []), describeIssue(issue, localize)]); + } + + const layout: SectionLayout[] = ENDPOINT_SECTIONS.filter( + (section) => !essentialsOnly || ESSENTIAL_SECTION_IDS.has(section.id), + ).flatMap((section) => { + const defs = (section.catchAll ? unplacedKeys(index) : section.fields).filter((def) => + index.has(def.key), + ); + const visible = defs.filter(isShown); + const hidden = disabled ? [] : defs.filter((def) => !isShown(def)); + if (visible.length === 0 && hidden.length === 0) return []; + return [{ section, visible, hidden, warnings: warnings.get(section.id) ?? [] }]; + }); + + const errorCount = (sectionId: string): number => + issues.filter( + (issue) => issue.severity === 'error' && sectionOfField(issue.field) === sectionId, + ).length; + + const sectionDomId = (id: string) => `${toFieldId(path)}-section-${id}`; + + return ( + + +
+ {!essentialsOnly && layout.length > 1 && ( + + )} + {layout.map(({ section, visible, hidden, warnings: sectionWarnings }) => { + const headingId = `${sectionDomId(section.id)}-heading`; + return ( +
+
+

+ {localize(section.labelKey)} +

+ {section.descriptionKey && ( +

+ {localize(section.descriptionKey)} +

+ )} +
+ {sectionWarnings.map((warning) => ( + + {warning} + + ))} + {visible.length > 0 ? ( +
{visible.map((def) => renderField(def.key))}
+ ) : ( +

+ {localize('com_config_ce_section_nothing_set')} +

+ )} + {hidden.length > 0 && ( +
+
+ ({ + value: def.key, + label: labelForPath(`${path}.${def.key}`, localize), + }))} + placeholder={localize('com_config_ce_add_setting')} + onChange={(key) => { + setShown((prev) => new Set(prev).add(key)); + setFocusKey(key); + }} + aria-label={localize('com_config_ce_add_setting_a11y', { + section: localize(section.labelKey), + })} + /> +
+
+ )} +
+ ); + })} + {!essentialsOnly && } +
+
+
+ ); +} diff --git a/src/components/configuration/sections/endpoints/Fields.tsx b/src/components/configuration/sections/endpoints/Fields.tsx new file mode 100644 index 00000000..bd2b2771 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Fields.tsx @@ -0,0 +1,520 @@ +/** + * Rows of the custom endpoint editor whose control the generic schema form + * cannot pick (WS10 CE-1, CE-5, CE-9): the name (locked for endpoints the + * layer beneath defines), the API key of a librechat.yaml endpoint (whose + * literal key LibreChat never sends back), and pickers for the endpoint or + * model another setting names. + */ +import { useId, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import { EModelEndpoint, paramSettings } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { useLocalize, useFieldError, useFieldState, useOptionSources } from '@/hooks'; +import { layerRevertTarget, lowerLayerValue } from '../../renderer/defaults'; +import { SecretInput, SelectField, TextField } from '../../fields'; +import { fieldDescription } from '../../renderer/paths'; +import { DecorativeIcon } from '@/components/shared'; +import { fieldMetaFor } from '../../renderer/kinds'; +import { SettingsRow } from '../../SettingsRow'; +import { toFieldId } from '../../renderer'; +import { labelForPath } from '../../meta'; +import { TagListField } from './Request'; + +/** Built-in providers a custom endpoint can hand title or label generation to. */ +const BUILT_IN_PROVIDERS: readonly string[] = [ + EModelEndpoint.openAI, + EModelEndpoint.azureOpenAI, + EModelEndpoint.anthropic, + EModelEndpoint.google, + EModelEndpoint.bedrock, +]; + +const CURRENT_MODEL = 'current_model'; +const DEFAULT_PARAM_SET = EModelEndpoint.custom as string; + +/** `bedrock-anthropic` → "Bedrock · Anthropic"; built-ins use their provider label. */ +function paramSetLabel(key: string, localize: t.LocalizeFn): string { + if (key === DEFAULT_PARAM_SET) return localize('com_config_ce_params_openai_compatible'); + if (key === 'openrouter') return 'OpenRouter'; + const [provider, variant] = key.split('-'); + const base = labelForPath(`endpoints.${provider}`, localize); + if (!variant) return base; + return `${base} · ${variant.charAt(0).toUpperCase()}${variant.slice(1)}`; +} + +/** Parameter sets LibreChat ships (`paramSettings`), for "Parameters from". */ +function paramSetOptions(localize: t.LocalizeFn): t.SelectOption[] { + return Object.keys(paramSettings).map((key) => ({ + value: key, + label: paramSetLabel(key, localize), + })); +} + +/** Endpoints a setting can name: the built-in providers, then the custom endpoints, without repeats. */ +function endpointOptions( + names: readonly string[], + fromSource: readonly t.SelectOption[], + localize: t.LocalizeFn, +): t.SelectOption[] { + const seen = new Set(); + const options: t.SelectOption[] = []; + const add = (option: t.SelectOption) => { + if (option.value === '' || seen.has(option.value)) return; + seen.add(option.value); + options.push(option); + }; + for (const provider of BUILT_IN_PROVIDERS) { + add({ value: provider, label: labelForPath(`endpoints.${provider}`, localize) }); + } + for (const name of names) add({ value: name, label: name }); + for (const option of fromSource) add(option); + return options; +} + +/** What clearing a field goes back to: the layer beneath's value when it sets one (D3), else nothing. */ +function useClearTarget(path: string): t.RevertTarget | undefined { + const state = useFieldState(path); + return state.isOverride ? layerRevertTarget(state.revertTarget) : lowerLayerValue(state); +} + +interface RowProps { + /** Data path of the field. */ + path: string; + /** Its schema field (label meta and help text). */ + field: t.SchemaField; + required?: boolean; + chips?: ReactNode; + control: ReactNode; + /** Shown under the control, muted (a non-blocking warning, a lock note). */ + note?: ReactNode; +} + +/** A settings row of the endpoint editor with the field's label, help and inline error. */ +export function EditorRow({ path, field, required, chips, control, note }: RowProps) { + const localize = useLocalize(); + const error = useFieldError(path); + return ( + + {control} + {note &&
{note}
} + + } + /> + ); +} + +/** + * The endpoint's name. An endpoint the layer beneath defines keeps its name + * (D1: LibreChat merges by name, so a rename would add a second endpoint). + */ +export function NameRow({ + path, + field, + value, + locked, + lockNote, + warning, + required, + chips, + disabled, + onChange, +}: { + path: string; + field: t.SchemaField; + value: string; + locked: boolean; + lockNote: string; + warning?: string; + required?: boolean; + chips?: ReactNode; + disabled?: boolean; + onChange: (value: string) => void; +}) { + const localize = useLocalize(); + const id = toFieldId(path); + if (locked || disabled) { + return ( + + {locked && ( + + + + )} + + {value} + + + } + note={locked ? lockNote : undefined} + /> + ); + } + return ( + + } + /> + ); +} + +/** + * The API key of an endpoint the layer beneath defines with a literal key: + * LibreChat redacts it on read and sends no preview, so it is not shown, not + * marked missing, and can only be replaced (CE-9). + */ +export function HiddenKeyRow({ + path, + field, + value, + chips, + disabled, + onChange, +}: { + path: string; + field: t.SchemaField; + value: t.ConfigValue; + chips?: ReactNode; + disabled?: boolean; + onChange: (value: t.ConfigValue) => void; +}) { + const localize = useLocalize(); + const id = toFieldId(path); + const pending = typeof value === 'string'; + const [replacing, setReplacing] = useState(pending); + if (!replacing || disabled) { + return ( + + + {localize('com_config_ce_key_hidden')} + + {!disabled && ( + - - {renderCollapsible( - isExpanded, - hasEverExpanded, -
- {showsGlobalNotice && ( - - )} - {flattenGroupFields( - fields, - parentValue, - parentPath, - onChange, - localize, - disabled, - collectionRenderOverrides, - editSessionId, - lockedKeys, - )} -
, - )} - - ); -} - -export function GroupedFieldRenderer({ - groupKey, - fields, - parentValue, - parentPath, - onChange, - disabled, - collectionRenderOverrides, - editSessionId, - lockedKeys, -}: { - groupKey: string; - fields: t.SchemaField[]; - parentValue: t.ConfigValue; - parentPath: string; - onChange: (path: string, value: t.ConfigValue) => void; - disabled?: boolean; - collectionRenderOverrides?: Record; - editSessionId?: number; - /** Fields shown read-only (the name of a librechat.yaml endpoint, D1). */ - lockedKeys?: ReadonlySet; -}) { - const groups = FIELD_GROUPS[groupKey]; - if (!groups) return null; - - const allGroupedKeys = new Set(groups.flatMap((g) => g.fields)); - const fieldsByKey = new Map(fields.map((f) => [f.key, f])); - const ungrouped = fields.filter((f) => !allGroupedKeys.has(f.key)); - - return ( -
- {groups.map((group) => { - const groupFields = group.fields - .map((key) => fieldsByKey.get(key)) - .filter((f): f is t.SchemaField => f != null); - return ( - - ); - })} - {ungrouped.length > 0 && ( - - )} -
- ); -} diff --git a/src/components/configuration/sections/endpoints/List.tsx b/src/components/configuration/sections/endpoints/List.tsx new file mode 100644 index 00000000..3ea9f046 --- /dev/null +++ b/src/components/configuration/sections/endpoints/List.tsx @@ -0,0 +1,368 @@ +import { useEffect, useRef } from 'react'; +import type * as t from '@/types'; +import { DecorativeIcon, KebabMenu } from '@/components/shared'; +import { useLocalize, useValuesScope } from '@/hooks'; +import { sourceLabel } from '../../values'; +import { describeIssue } from './issues'; +import { isUnserved } from './model'; +import { cn } from '@/utils'; + +/** Two letters for the endpoint's monogram: initials of its words, else its first two characters. */ +function monogram(title: string): string { + const words = title.split(/[\s_-]+/).filter(Boolean); + if (words.length >= 2) return `${words[0][0]}${words[1][0]}`.toUpperCase(); + return title.slice(0, 2).toUpperCase(); +} + +function Monogram({ title, muted }: { title: string; muted?: boolean }) { + return ( + + ); +} + +/** A chip in a list row or the drawer header; `description` is read after the label. */ +export function Badge({ + tone, + label, + description, + dot, +}: { + tone: 'yaml' | 'override' | 'scope' | 'inherited' | 'added' | 'edited' | 'error' | 'warning'; + label: string; + description?: string; + dot?: boolean; +}) { + return ( + + {dot && + ); +} + +/** + * Where an endpoint comes from (CE-2): "librechat.yaml", "librechat.yaml + + * overrides", "Added in admin"; in a profile "Inherited from Base", "Base + + * Role: USER" or "Added in Role: USER". + */ +export function SourceBadge({ row }: { row: Pick }) { + const localize = useLocalize(); + const scope = useValuesScope(); + const scopeName = scope ? sourceLabel('scope', localize, scope) : ''; + switch (row.origin) { + case 'yaml': + return row.overridden ? ( + + ) : ( + + ); + case 'admin': + return ( + + ); + case 'inherited': + return row.overridden ? ( + + ) : ( + + ); + default: + return ( + + ); + } +} + +/** "Incomplete – not served", "Needs attention" or nothing (CE-3). */ +export function StatusBadge({ issues }: { issues: t.EndpointIssue[] }) { + const localize = useLocalize(); + const errors = issues.filter((issue) => issue.severity === 'error'); + if (errors.length === 0) return null; + const summary = errors.map((issue) => describeIssue(issue, localize)).join(' '); + return isUnserved(errors) ? ( + + ) : ( + + ); +} + +/** "New" or "Edited" for an endpoint with unsaved changes. */ +function PendingBadge({ row }: { row: t.EndpointRow }) { + const localize = useLocalize(); + if (row.isNew) { + return ( + + ); + } + if (!row.edited) return null; + return ( + + ); +} + +function rowDetails(row: t.EndpointRow, localize: t.LocalizeFn): string { + const parts = [row.host ?? localize('com_config_ce_no_base_url')]; + parts.push(localize('com_config_ce_models_count', { count: row.modelCount })); + if (row.fetchesModels) parts.push(localize('com_config_ce_models_fetched')); + return parts.join(' · '); +} + +/** Whether a row can be deleted here: endpoints the layer beneath defines can't (D1). */ +export function isDeletable(row: Pick): boolean { + return row.origin === 'admin' || row.origin === 'scope'; +} + +/** A saved endpoint a pending edit removes: struck through, with Undo (D9). */ +function RemovalRow({ + removal, + disabled, + onUndo, +}: { + removal: t.EndpointRemoval; + disabled?: boolean; + onUndo: (removal: t.EndpointRemoval) => void; +}) { + const localize = useLocalize(); + return ( +
  • + + + + {removal.title} + + + {localize('com_config_ctl_entry_pending_delete')} + + + {!disabled && ( + + )} +
  • + ); +} + +/** + * One endpoint of the list. Its row is the target the table of contents, + * search and the review's "Go to field" reveal: while its editor is closed it + * reads as collapsed (a closed grid marker), and the reveal's `config:expand` + * opens the editor. + */ +function EndpointListRow({ + row, + idPrefix, + isOpen, + disabled, + onOpen, + onReveal, + onDelete, + onRevert, +}: t.EndpointListRowProps) { + const localize = useLocalize(); + const rowRef = useRef(null); + const onRevealRef = useRef(onReveal); + onRevealRef.current = onReveal; + const detailsId = `${idPrefix}-${row.index}-details`; + + useEffect(() => { + const element = rowRef.current; + if (!element) return; + const reveal = () => onRevealRef.current(row); + element.addEventListener('config:expand', reveal); + return () => element.removeEventListener('config:expand', reveal); + }, [row]); + + const menu: t.KebabMenuItem[] = [ + { + label: localize(disabled ? 'com_config_ce_action_view' : 'com_config_ce_action_edit'), + icon: 'pencil', + onClick: () => onOpen(row), + }, + ]; + if (!disabled && row.overridden) { + menu.push({ + label: localize('com_config_ce_action_revert'), + icon: 'history', + onClick: () => onRevert(row), + }); + } + if (!disabled) { + const deletable = isDeletable(row); + menu.push({ + label: localize('com_config_ce_action_delete'), + icon: 'trash', + danger: deletable, + disabled: !deletable, + title: deletable + ? undefined + : localize( + row.origin === 'inherited' + ? 'com_config_ce_delete_locked_inherited' + : 'com_config_ce_delete_locked', + ), + onClick: () => onDelete(row), + }); + } + + return ( +
  • + {!isOpen && } + +
    +
    + + + + +
    + + + {rowDetails(row, localize)} + + {row.fetchesModels && ( + + + + )} + +
    +
    + +
    +
  • + ); +} + +/** + * The custom endpoint list (WS10 CE-7): one compact row per endpoint with its + * monogram, name, host, model count (and whether the list is fetched), where + * it comes from, its status and an actions menu. Selecting a row opens the + * editor. Pending deletions stay listed with Undo until Save (D9). + */ +export function EndpointList({ + rows, + removals, + openKey, + disabled, + onOpen, + onReveal, + onDelete, + onRevert, + onUndoRemoval, + idPrefix, +}: t.EndpointListProps) { + const localize = useLocalize(); + const items: Array< + { kind: 'row'; row: t.EndpointRow } | { kind: 'removed'; removal: t.EndpointRemoval } + > = rows.map((row) => ({ kind: 'row' as const, row })); + for (const removal of [...removals].sort((a, b) => a.index - b.index)) { + items.splice(Math.min(removal.index, items.length), 0, { kind: 'removed', removal }); + } + + return ( +
      + {items.map((item) => + item.kind === 'removed' ? ( + + ) : ( + + ), + )} +
    + ); +} diff --git a/src/components/configuration/sections/endpoints/ParamDefinitions.tsx b/src/components/configuration/sections/endpoints/ParamDefinitions.tsx index 631663e9..fa21b08e 100644 --- a/src/components/configuration/sections/endpoints/ParamDefinitions.tsx +++ b/src/components/configuration/sections/endpoints/ParamDefinitions.tsx @@ -1,10 +1,30 @@ +/** + * Parameter definitions of a custom endpoint (WS10 CE-6): the parameter + * panel its users see. An entry that only sets the default of a built-in + * parameter (`{ key, default }`, or the built-in definition with only its + * default changed) reads as such, with a default control of the parameter's + * own type; "Customize every option" opens the full definition, + * whose Default follows the chosen Type, whose Component choices fit that + * Type, and whose built-in (locale-coded) label and description are shown as + * such with a switch to custom text. Definitions librechat.yaml (or Base) + * defines keep their key: LibreChat would bring the original back. + */ +import { useMemo, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import { paramSettings } from 'librechat-data-provider'; +import type { SettingDefinition } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import type * as t from '@/types'; -import { renderInlineField } from '../../renderer'; +import { ArrayObjectField, ListField, NumberField, SelectField, TextField } from '../../fields'; +import { renderInlineField, toFieldId } from '../../renderer'; +import { getEntryKey, isConfigRecord, isConfigValueEqual } from '@/utils'; +import { DecorativeIcon } from '@/components/shared'; +import { SettingsRow } from '../../SettingsRow'; +import { describeField, labelForPath } from '../../meta'; import { useLocalize } from '@/hooks'; -/** Maps type → valid component choices. */ -const TYPE_COMPONENTS: Record = { +/** Components that can show each type of parameter. */ +const TYPE_COMPONENTS: Readonly> = { number: ['input', 'slider'], boolean: ['checkbox', 'switch'], string: ['input', 'textarea'], @@ -12,131 +32,643 @@ const TYPE_COMPONENTS: Record = { array: ['tags'], }; -/** Maps component → extra fields that become relevant. */ -const COMPONENT_FIELDS: Record = { - input: ['minText', 'maxText', 'placeholder'], - textarea: ['minText', 'maxText', 'placeholder'], +/** Fields each component reads. */ +const COMPONENT_FIELDS: Readonly> = { + input: ['placeholder', 'minText', 'maxText'], + textarea: ['placeholder', 'minText', 'maxText'], slider: ['range', 'includeInput'], checkbox: [], switch: [], - dropdown: ['options'], - combobox: [ - 'options', - 'items', - 'searchPlaceholder', - 'selectPlaceholder', - 'searchPlaceholderCode', - 'selectPlaceholderCode', - ], + dropdown: ['options', 'enumMappings'], + combobox: ['options', 'searchPlaceholder', 'selectPlaceholder'], tags: ['minTags', 'maxTags'], }; -/** Fields always visible regardless of type/component. */ -const ALWAYS_VISIBLE = new Set(['key', 'type', 'component', 'description', 'default', 'label']); +/** Components whose options are a list of choices (a checkbox or switch ignores them). */ +const OPTION_COMPONENTS: ReadonlySet = new Set(['dropdown', 'combobox', 'slider']); -/** Explicit field ordering for paramDefinition entries. Fields listed here - * appear first in this order; unlisted fields follow in schema order. */ -const PARAM_FIELD_ORDER = [ +/** How the definition is laid out, after key, type, component and default. */ +const LAYOUT_FIELDS = ['optionType', 'showLabel', 'showDefault', 'columnSpan', 'columns']; + +/** Fields a built-in definition may differ in and still read as "the built-in, with its default changed". */ +const OVERRIDE_FIELDS: ReadonlySet = new Set(['default', 'optionType']); + +/** Fields placed by hand (the rest render through the generic entry renderer). */ +const HANDLED: ReadonlySet = new Set([ 'key', - 'description', - 'label', 'type', 'component', 'default', - 'options', -]; + 'label', + 'labelCode', + 'description', + 'descriptionCode', +]); -/** Fields visible when options are relevant (enum, dropdown, combobox, checkbox, switch). */ -const OPTIONS_TYPES = new Set(['enum']); -const OPTIONS_COMPONENTS = new Set(['dropdown', 'combobox', 'checkbox', 'switch', 'slider']); +function asRecord(value: t.ConfigValue): t.ConfigRecord { + return isConfigRecord(value) ? value : {}; +} -function ParamDefinitionEntry({ - fields, - parentValue, - parentPath, - onChange, +function text(value: t.ConfigValue): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function displayValue(value: t.ConfigValue): string { + return Array.isArray(value) ? value.join(', ') : String(value); +} + +/** + * A built-in parameter as a stored definition. LibreChat expands + * librechat.yaml's definitions over the built-in ones (`parseCustomParams`) + * but serves an override's as stored, and its client replaces the built-in + * definition with it whole, so a bare `{ key, default }` would lose its type, + * component and label: store the full definition, as LibreChat expands it. + */ +export function builtInDefinition(builtIn: SettingDefinition): t.ConfigRecord { + const definition: t.ConfigValue = JSON.parse(JSON.stringify(builtIn)); + return { ...asRecord(definition), optionType: 'custom' }; +} + +/** Whether a definition is the built-in one with at most its default changed (or only `{ key, default }`). */ +function isBuiltInOverride(value: t.ConfigRecord, builtIn: SettingDefinition | undefined): boolean { + if (!builtIn) return false; + if (value.type === undefined && value.component === undefined) return true; + const full = builtInDefinition(builtIn); + const keys = new Set([...Object.keys(value), ...Object.keys(full)]); + return [...keys].every( + (key) => OVERRIDE_FIELDS.has(key) || isConfigValueEqual(value[key], full[key]), + ); +} + +/** The built-in parameters of a parameter set, by key (LibreChat's `paramSettings`). */ +function builtInParameters(paramSet: string | undefined): Map { + const settings = paramSettings[paramSet ?? 'custom'] ?? paramSettings.custom ?? []; + return new Map(settings.map((setting) => [setting.key, setting])); +} + +function Row({ + path, + control, + description, + note, }: { - fields: t.SchemaField[]; - parentValue: t.ConfigValue; - parentPath: string; - onChange: (path: string, value: t.ConfigValue) => void; + path: string; + control: ReactNode; + description?: string; + note?: ReactNode; }) { const localize = useLocalize(); - const values = - typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; - - const currentType = typeof values.type === 'string' ? values.type : ''; - const currentComponent = typeof values.component === 'string' ? values.component : ''; + return ( + + {control} + {note &&
    {note}
    } + + } + /> + ); +} - // Build the set of visible field keys based on current type + component - const visibleKeys = new Set(ALWAYS_VISIBLE); +/** The default value, with a control for the parameter's type. */ +function DefaultControl({ + path, + type, + value, + options, + range, + onChange, + disabled, +}: { + path: string; + type: string | undefined; + value: t.ConfigValue; + options: string[]; + range: t.ConfigRecord; + onChange: (value: t.ConfigValue) => void; + disabled?: boolean; +}) { + const localize = useLocalize(); + const id = toFieldId(path); + const label = labelForPath(path, localize); + const clear = () => { + onChange(undefined); + return true; + }; + if (disabled) { + return ( + + {value === undefined || value === '' + ? localize('com_config_ce_param_no_default') + : displayValue(value)} + + ); + } + switch (type) { + case 'boolean': + return ( + onChange(next === 'true')} + onClear={clear} + defaultLabel={localize('com_config_ce_param_no_default')} + width="S" + aria-label={label} + /> + ); + case 'number': + return ( + onChange(next)} + onClear={clear} + min={typeof range.min === 'number' ? range.min : undefined} + max={typeof range.max === 'number' ? range.max : undefined} + step={typeof range.step === 'number' ? range.step : undefined} + width="S" + aria-label={label} + /> + ); + case 'enum': + return ( + ({ value: option, label: option }))} + onChange={onChange} + onClear={clear} + defaultLabel={localize('com_config_ce_param_no_default')} + width="M" + aria-label={label} + /> + ); + case 'array': + return ( + typeof item === 'string') : []} + onChange={onChange} + onClear={clear} + width="L" + aria-label={label} + /> + ); + default: + return ( + + ); + } +} - if (currentType && OPTIONS_TYPES.has(currentType)) { - visibleKeys.add('options'); - visibleKeys.add('enumMappings'); +/** + * A label or description LibreChat looks up in its own translations + * (`labelCode`): shown as the built-in text, with a switch to custom text. + */ +function CodedText({ + path, + codeKey, + values, + onChange, + disabled, + wide, +}: { + path: string; + codeKey: string; + values: t.ConfigRecord; + onChange: (key: string, value: t.ConfigValue) => void; + disabled?: boolean; + wide?: boolean; +}) { + const localize = useLocalize(); + const key = path.split('.').pop() ?? ''; + const id = toFieldId(path); + const current = text(values[key]); + if (values[codeKey] === true) { + return ( + + + {localize('com_config_ce_param_builtin_text')} + + {!disabled && ( + + )} + + ))} + + )} + {!disabled && ( + <> + { + const next = event.target.value; + if (next.endsWith(',')) add(next); + else setDraft(next); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + add(draft); + } + if (event.key === 'Backspace' && draft === '' && values.length > 0) { + remove(values[values.length - 1]); + } + }} + onBlur={() => add(draft)} + placeholder={localize('com_config_ce_drop_params_placeholder')} + aria-label={ariaLabel} + className={cn(INPUT_CLASSES, 'max-w-80')} + /> + + {COMMON_DROP_PARAMS.filter((param) => !taken.has(param)).map((param) => ( + + + )} + + ); +} + +/** A header's value input (never its name): the only place a placeholder goes. */ +function isHeaderValueInput(element: EventTarget | null): element is HTMLInputElement { + return ( + element instanceof HTMLInputElement && + element.type === 'text' && + element.dataset.kvPart === 'value' + ); +} + +/** Types `text` into a React-controlled input at its cursor, as if the admin had typed it. */ +function insertAtCursor(input: HTMLInputElement, text: string) { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + const next = `${input.value.slice(0, start)}${text}${input.value.slice(end)}`; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, next); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + input.setSelectionRange(start + text.length, start + text.length); +} + +/** + * The headers editor with LibreChat's placeholders under it: picking one + * inserts it at the cursor of the header value last edited (or copies it + * when no header value has been focused yet). + */ +export function HeaderPlaceholders({ children }: { children: ReactNode }) { + const localize = useLocalize(); + const lastInputRef = useRef(null); + const [copied, setCopied] = useState(null); + + const insert = (placeholder: string) => { + const input = lastInputRef.current; + if (input && input.isConnected && !input.disabled) { + insertAtCursor(input, placeholder); + setCopied(null); + return; + } + void navigator.clipboard?.writeText(placeholder).then( + () => setCopied(placeholder), + () => setCopied(null), + ); + }; + + return ( +
    { + if (isHeaderValueInput(event.target)) lastInputRef.current = event.target; + }} + > + {children} +
    + +
    + ); +} diff --git a/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx b/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx index ae9b0e51..4e60b27f 100644 --- a/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx +++ b/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx @@ -1,36 +1,292 @@ -import { describe, it, expect, vi } from 'vitest'; +import { useState } from 'react'; import { configSchema } from 'librechat-data-provider'; -import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, fireEvent, within, act } from '@testing-library/react'; import type * as t from '@/types'; +import { + valuesDataFixture, + baseConfigFixture, + baseLayersFixture, + scopeLayersFixture, +} from '@/test/layers'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; import { CustomEndpointsRenderer } from '../CustomEndpointsRenderer'; import { extractSchemaTree } from '@/server/config'; +import { isConfigRecord } from '@/utils'; -vi.mock('@/hooks/useLocalize', () => ({ - default: () => (key: string) => key, - useLocalize: () => (key: string) => key, -})); +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, params?: Record) => + params?.name ? `${key}:${params.name}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +/** jsdom has no ResizeObserver; Radix checkboxes inside a form measure themselves with one. */ +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', NoopResizeObserver); +}); const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); -const endpoints = tree.find((field) => field.key === 'endpoints'); +const fields = tree.find((field) => field.key === 'endpoints')?.children ?? []; + +const ADMIN_ENTRY: t.ConfigRecord = { + name: 'Mine', + apiKey: 'user_provided', + baseURL: 'https://mine.example.com/v1', + models: { default: ['m-1'] }, +}; + +/** Base: librechat.yaml's GPT and Claude, GPT's base URL overridden, and `Mine` added in the panel. */ +function baseLayers(): t.ValueLayers { + return baseLayersFixture( + baseConfigFixture({ + dbOverrides: { + endpoints: { + custom: [{ name: 'GPT', baseURL: 'https://override.example.com' }, ADMIN_ENTRY], + }, + }, + }), + ); +} + +function savedList(layers: t.ValueLayers): t.ConfigValue[] { + const endpoints = layers.effective?.endpoints; + const list = + endpoints && typeof endpoints === 'object' && !Array.isArray(endpoints) + ? endpoints.custom + : undefined; + return Array.isArray(list) ? list : []; +} + +function Harness({ + layers, + initial, + onChange, + collectionEdits = {}, + showConfiguredOnly, +}: { + layers: t.ValueLayers; + initial?: t.ConfigValue[]; + onChange?: (path: string, value: t.ConfigValue) => void; + collectionEdits?: t.CollectionEdits; + showConfiguredOnly?: boolean; +}) { + const [items, setItems] = useState(initial ?? savedList(layers)); + const handleChange = (path: string, value: t.ConfigValue) => { + onChange?.(path, value); + if (path === 'endpoints.custom' && Array.isArray(value)) setItems(value); + const match = /^endpoints\.custom\.(\d+)$/.exec(path); + if (match) setItems((prev) => prev.map((item, i) => (i === Number(match[1]) ? value : item))); + }; + return ( + + + (path === 'endpoints.custom' ? items : fallback)} + onChange={handleChange} + showConfiguredOnly={showConfiguredOnly} + /> + + + ); +} + +function row(name: string): HTMLElement { + const element = document.querySelector(`li[data-endpoint-row="${name}"]`); + if (!element) throw new Error(`no row for ${name}`); + return element; +} + +function openRow(name: string): HTMLElement { + fireEvent.click(within(row(name)).getByRole('button', { name })); + return screen.getByRole('dialog'); +} + +async function typeInto(selector: string, text: string) { + const input = document.querySelector(selector); + if (!input) throw new Error(`no ${selector}`); + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); + await act(async () => { + vi.runOnlyPendingTimers(); + }); +} describe('CustomEndpointsRenderer', () => { + it('lists each endpoint with where it comes from and its host', () => { + render(); + expect(within(row('GPT')).getByText('com_config_ce_source_yaml_overrides')).toBeInTheDocument(); + expect(within(row('Claude')).getByText('com_config_ce_source_yaml')).toBeInTheDocument(); + expect(within(row('Mine')).getByText('com_config_ce_source_admin')).toBeInTheDocument(); + expect(within(row('GPT')).getByText(/override\.example\.com/)).toBeInTheDocument(); + expect(within(row('Mine')).queryByText('com_config_ce_status_unserved')).toBeNull(); + }); + + it('marks an endpoint LibreChat would not serve (CE-3)', () => { + render(); + expect(within(row('OnlyName')).getByText('com_config_ce_status_unserved')).toBeInTheDocument(); + }); + + it('keeps the name of a librechat.yaml endpoint read-only and offers no delete (D1)', () => { + render(); + const drawer = openRow('Claude'); + const name = drawer.querySelector('#endpoints-custom-1-name'); + expect(name?.tagName).not.toBe('INPUT'); + expect(name).toHaveTextContent('Claude'); + expect( + within(drawer).queryByRole('button', { name: 'com_config_ce_action_delete' }), + ).toBeNull(); + }); + + it('lets an endpoint added in the panel be renamed and deleted', () => { + const onChange = vi.fn(); + render(); + const drawer = openRow('Mine'); + expect(drawer.querySelector('input#endpoints-custom-2-name')).not.toBeDisabled(); + fireEvent.click(within(drawer).getByRole('button', { name: 'com_config_ce_action_delete' })); + expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', [ + expect.objectContaining({ name: 'GPT' }), + expect.objectContaining({ name: 'Claude' }), + ]); + }); + it('leaves a cleared name editable when the layer beneath defines no endpoints', () => { - const { container } = render( - fallback} - onChange={vi.fn()} - />, + const layers = baseLayersFixture( + baseConfigFixture({ yamlConfig: {}, config: {}, yamlPaths: [], dbOverrides: {} }), + ); + render( + , + ); + fireEvent.click(document.querySelector('li[data-endpoint-row] button') as HTMLElement); + expect(document.querySelector('input#endpoints-custom-0-name')).not.toBeDisabled(); + }); + + it('lists a pending deletion from the edit state, with Undo (D9)', () => { + const onChange = vi.fn(); + const layers = baseLayers(); + const saved = savedList(layers); + const remaining = saved.slice(0, 2); + const edits: t.CollectionEdits = { + 'endpoints.custom': { + baseline: saved, + origins: remaining.map((entry, i) => ({ + identity: ['GPT', 'Claude'][i], + baseline: entry, + })), + detached: [{ value: saved[2], origin: { identity: 'Mine', baseline: saved[2] } }], + }, + }; + render( + , + ); + expect(screen.getByText('com_config_ctl_entry_pending_delete')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_ctl_entry_undo_a11y:Mine' })); + expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', [...remaining, saved[2]]); + }); + + it('restores an undone deletion relative to the surviving endpoints', () => { + const onChange = vi.fn(); + const entry = (name: string): t.ConfigRecord => ({ ...ADMIN_ENTRY, name }); + const saved = [entry('A'), entry('B'), entry('C')]; + const layers = baseLayersFixture( + baseConfigFixture({ + yamlConfig: {}, + config: {}, + yamlPaths: [], + dbOverrides: { endpoints: { custom: saved } }, + }), ); - const toggle = container.querySelector( - '[data-entry-card] button[aria-expanded]', + const remaining = [saved[2]]; + const edits: t.CollectionEdits = { + 'endpoints.custom': { + baseline: saved, + origins: [{ identity: 'C', baseline: saved[2] }], + detached: [ + { value: saved[0], origin: { identity: 'A', baseline: saved[0] } }, + { value: saved[1], origin: { identity: 'B', baseline: saved[1] } }, + ], + }, + }; + render( + , ); - if (!toggle) throw new Error('no entry card'); - fireEvent.click(toggle); - const name = container.querySelector('#endpoints-custom-0-name'); - expect(name).not.toBeNull(); - expect(name).not.toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_ctl_entry_undo_a11y:B' })); + expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', [saved[1], saved[2]]); + fireEvent.click(screen.getByRole('button', { name: 'com_config_ctl_entry_undo_a11y:A' })); + expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', saved); + }); + + it('opens an inherited endpoint read-only until it is overridden for the profile (CE-8)', () => { + render(); + const drawer = openRow('Claude'); + const baseURL = () => drawer.querySelector('#endpoints-custom-1-baseURL'); + expect(baseURL()?.tagName).not.toBe('INPUT'); + fireEvent.click(within(drawer).getByRole('button', { name: 'com_config_ce_override_for' })); + expect(baseURL()?.tagName).toBe('INPUT'); + }); + + it('keeps a pending override of an inherited endpoint listed under "set values only"', () => { + const layers = scopeLayersFixture(); + const saved = savedList(layers); + const { rerender } = render(); + expect(document.querySelector('li[data-endpoint-row="Claude"]')).toBeNull(); + const edited = saved.map((entry) => + isConfigRecord(entry) && entry.name === 'Claude' + ? { ...entry, baseURL: 'https://edited.example.com' } + : entry, + ); + rerender(); + expect(row('Claude')).toBeInTheDocument(); + }); + + it('keeps Create disabled until the endpoint can be served, then adds it', async () => { + vi.useFakeTimers(); + try { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_create_endpoint' })); + fireEvent.click(screen.getByRole('button', { name: /com_config_ce_template_blank_name/ })); + const create = screen.getByRole('button', { name: 'com_config_ce_create' }); + expect(create).toBeDisabled(); + + await typeInto('#endpoints-custom-9999-name', 'openAI'); + expect(screen.getByText('com_config_ce_issue_name_reserved')).toBeInTheDocument(); + await typeInto('#endpoints-custom-9999-name', 'New One'); + await typeInto('#endpoints-custom-9999-baseURL', 'https://new.example.com/v1'); + await typeInto('#endpoints-custom-9999-apiKey', '${NEW_KEY}'); + const models = document.querySelector( + '#endpoints-custom-9999-models-default input:not([type="hidden"])', + ); + if (!models) throw new Error('no model input'); + fireEvent.change(models, { target: { value: 'new-model' } }); + fireEvent.keyDown(models, { key: 'Enter' }); + await act(async () => { + vi.runOnlyPendingTimers(); + }); + expect(create).not.toBeDisabled(); + fireEvent.click(create); + expect(onChange).toHaveBeenLastCalledWith( + 'endpoints.custom', + expect.arrayContaining([ + { + name: 'New One', + baseURL: 'https://new.example.com/v1', + apiKey: '${NEW_KEY}', + models: { default: ['new-model'] }, + }, + ]), + ); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx b/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx new file mode 100644 index 00000000..7f46d058 --- /dev/null +++ b/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +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 { HeaderPlaceholders, TagListField } from '../Request'; +import { ParamDefinitionsRow } from '../ParamDefinitions'; +import { extractSchemaTree } from '@/server/config'; +import { KeyValueField } from '../../../fields'; +import { PricingRow } from '../Pricing'; + +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, params?: Record) => + params?.name ? `${key}:${params.name}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', NoopResizeObserver); + Element.prototype.scrollIntoView = vi.fn(); +}); + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const custom = tree + .find((field) => field.key === 'endpoints') + ?.children?.find((field) => field.key === 'custom'); +const customParams = custom?.children?.find((field) => field.key === 'customParams'); +const paramDefinitionsField = customParams?.children?.find( + (field) => field.key === 'paramDefinitions', +); +const PATH = 'endpoints.custom.0.customParams.paramDefinitions'; + +function Params({ + initial, + lowerKeys = new Set(), + onChange, +}: { + initial: t.ConfigValue[]; + lowerKeys?: ReadonlySet; + onChange?: (value: t.ConfigValue) => void; +}) { + const [value, setValue] = useState(initial); + if (!paramDefinitionsField) throw new Error('no paramDefinitions field'); + return ( + { + onChange?.(next); + setValue(next); + }} + /> + ); +} + +function expandFirstCard(container: HTMLElement) { + const header = container.querySelector( + '[data-entry-card] button[aria-expanded]', + ); + if (!header) throw new Error('no card'); + if (header.getAttribute('aria-expanded') !== 'true') fireEvent.click(header); +} + +describe('ParamDefinitionsRow (CE-6)', () => { + it('titles a definition by its key and shows its default with a control of its type', () => { + const { container } = render( + , + ); + expect(screen.getByText('useResponsesApi')).toBeInTheDocument(); + expandFirstCard(container); + const defaultSelect = container.querySelector( + `#${PATH.replace(/\./g, '-')}-0-default`, + ); + expect(defaultSelect?.value).toBe('true'); + expect(screen.getAllByText('com_config_ce_param_builtin_text').length).toBeGreaterThan(0); + }); + + it('changes the type in one update that drops a component and default that no longer fit', () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + expandFirstCard(container); + const type = container.querySelector(`#${PATH.replace(/\./g, '-')}-0-type`); + if (!type) throw new Error('no type select'); + fireEvent.change(type, { target: { value: 'number' } }); + expect(onChange).toHaveBeenLastCalledWith([{ key: 'flag', type: 'number' }]); + const component = container.querySelector( + `#${PATH.replace(/\./g, '-')}-0-component`, + ); + const choices = [...(component?.options ?? [])].map((option) => option.value); + expect(choices).toContain('slider'); + expect(choices).not.toContain('switch'); + }); + + it('keeps the key of a definition the layer beneath defines, and offers no delete', () => { + const { container } = render( + , + ); + expandFirstCard(container); + expect(container.querySelector(`input#${PATH.replace(/\./g, '-')}-0-key`)).toBeNull(); + expect(screen.getByText('com_config_ce_param_key_locked')).toBeInTheDocument(); + expect(container.querySelector('button[aria-label^="com_ui_delete"]')).toBeNull(); + }); + + it('adds an override of a built-in parameter as its complete definition, shown as a default override', () => { + const onChange = vi.fn(); + const { container } = render(); + const select = screen.getByLabelText('com_config_ce_param_add_builtin'); + fireEvent.change(select, { target: { value: 'temperature' } }); + const [added] = onChange.mock.lastCall?.[0] as t.ConfigRecord[]; + const builtIn = paramSettings.custom?.find((setting) => setting.key === 'temperature'); + expect(added).toEqual({ ...builtIn, optionType: 'custom' }); + expect(added).toMatchObject({ type: 'number', component: 'slider', labelCode: true }); + expandFirstCard(container); + expect(screen.getByText('com_config_ce_param_override_note')).toBeInTheDocument(); + expect(container.querySelector(`#${PATH.replace(/\./g, '-')}-0-type`)).toBeNull(); + }); +}); + +describe('TagListField', () => { + it('adds comma-separated names, skips repeats and clears when the last is removed', () => { + const onChange = vi.fn(); + const onClear = vi.fn(); + const { rerender } = render( + , + ); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'user, stop, top_p' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenLastCalledWith(['stop', 'user', 'top_p']); + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_remove_item:stop' })); + expect(onClear).toHaveBeenCalled(); + }); +}); + +describe('HeaderPlaceholders', () => { + it('inserts a placeholder into the header value last focused, never into a header name', () => { + const writeText = vi.fn(() => Promise.resolve()); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + try { + render( + + + , + ); + const name = screen.getByLabelText('com_ui_key 1'); + const value = screen.getByLabelText('com_ui_value 1'); + const placeholder = screen.getByRole('button', { name: '{{LIBRECHAT_USER_ID}}' }); + + fireEvent.focus(name); + fireEvent.click(placeholder); + expect(name.value).toBe('X-User'); + expect(writeText).toHaveBeenCalledWith('{{LIBRECHAT_USER_ID}}'); + + fireEvent.focus(value); + value.setSelectionRange(3, 3); + fireEvent.focus(name); + fireEvent.click(placeholder); + expect(name.value).toBe('X-User'); + expect(value.value).toBe('id-{{LIBRECHAT_USER_ID}}'); + expect(writeText).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('ResizeObserver', NoopResizeObserver); + } + }); +}); + +describe('PricingRow', () => { + it('edits rates per model and keeps models the layer beneath prices', () => { + const onChange = vi.fn(); + render( + , + ); + const table = screen.getByRole('table'); + expect(within(table).getAllByRole('row')).toHaveLength(3); + expect(screen.getByText('com_config_ce_pricing_missing')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'com_ui_remove_item:model-a' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_remove_item:model-b' })); + expect(onChange).toHaveBeenLastCalledWith({ + 'model-a': { prompt: 1, completion: 2, context: 1000 }, + }); + const add = screen.getByPlaceholderText('com_config_ce_pricing_add_placeholder'); + fireEvent.change(add, { target: { value: 'model-c' } }); + fireEvent.keyDown(add, { key: 'Enter' }); + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ 'model-c': {} })); + }); +}); diff --git a/src/components/configuration/sections/endpoints/__tests__/model.test.ts b/src/components/configuration/sections/endpoints/__tests__/model.test.ts new file mode 100644 index 00000000..d4624aa8 --- /dev/null +++ b/src/components/configuration/sections/endpoints/__tests__/model.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import type * as t from '@/types'; +import { + modelNames, + isUnserved, + endpointHost, + isUsableBaseURL, + validateEndpoint, + hasBlockingIssue, +} from '../model'; + +const complete: t.ConfigRecord = { + name: 'OpenRouter', + apiKey: '${OPENROUTER_KEY}', + baseURL: 'https://openrouter.ai/api/v1', + models: { default: ['openai/gpt-4o-mini'] }, +}; + +function codes(input: Partial & { entry: t.ConfigValue }): string[] { + return validateEndpoint({ + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + ...input, + }).map((issue) => issue.code); +} + +describe('validateEndpoint', () => { + it('accepts a complete endpoint', () => { + expect(codes({ entry: complete })).toEqual([]); + }); + + it('flags a name-only endpoint as not served (custom-08)', () => { + const issues = validateEndpoint({ + entry: { name: 'OnlyName' }, + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues.map((issue) => issue.code)).toEqual([ + 'apiKeyRequired', + 'baseUrlRequired', + 'modelsRequired', + ]); + expect(isUnserved(issues)).toBe(true); + expect(hasBlockingIssue(issues)).toBe(true); + }); + + it('requires a default model even when the list is fetched, but serves it', () => { + const entry = { ...complete, models: { default: [], fetch: true } }; + const issues = validateEndpoint({ + entry, + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues.map((issue) => issue.code)).toEqual(['modelsRequired']); + expect(isUnserved(issues)).toBe(false); + }); + + it('rejects reserved, padded, blank and taken names', () => { + expect(codes({ entry: { ...complete, name: 'openAI' } })).toEqual(['nameReserved']); + expect(codes({ entry: { ...complete, name: ' GPT' } })).toEqual(['nameWhitespace']); + expect(codes({ entry: { ...complete, name: ' ' } })).toEqual(['nameRequired']); + expect(codes({ entry: complete, otherNames: ['OpenRouter'] })).toEqual(['nameTaken']); + }); + + it('only warns about a name that differs in letter case', () => { + const issues = validateEndpoint({ + entry: complete, + otherNames: ['openrouter'], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues).toEqual([ + { field: 'name', code: 'nameSimilar', severity: 'warning', params: { name: 'openrouter' } }, + ]); + }); + + it('flags blank models and warns about repeated ones (custom-09)', () => { + const entry = { + ...complete, + models: { default: ['@cf/gemma', '', '@cf/gemma', { name: 'kimi', description: 'K' }] }, + }; + const issues = validateEndpoint({ + entry, + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues.map((issue) => [issue.code, issue.severity])).toEqual([ + ['modelBlank', 'error'], + ['modelDuplicate', 'warning'], + ]); + expect(issues[1].params).toEqual({ models: '@cf/gemma' }); + }); + + it('does not ask for the key or name of an endpoint librechat.yaml defines (CE-9)', () => { + const yamlEntry = { + name: 'Ollama', + baseURL: 'http://ollama:11434/v1', + models: { default: ['x'] }, + }; + expect(codes({ entry: yamlEntry, fromLowerLayer: true, otherNames: ['Ollama'] })).toEqual([]); + }); + + it('counts a stored key and asks for it again on rename (CE-1)', () => { + const stored = { ...complete, apiKey: undefined, apiKeyPreview: '...0XYZ' }; + expect(codes({ entry: stored, storedSecret: true, loadedName: 'OpenRouter' })).toEqual([]); + expect( + codes({ + entry: { ...stored, name: 'Renamed' }, + storedSecret: true, + loadedName: 'OpenRouter', + }), + ).toEqual(['renameNeedsKey']); + expect( + codes({ + entry: { ...stored, name: 'Renamed', apiKey: 'sk-new' }, + storedSecret: true, + loadedName: 'OpenRouter', + }), + ).toEqual([]); + }); + + it('warns about a base URL that is not a URL, but accepts env references', () => { + expect(codes({ entry: { ...complete, baseURL: 'api.example.com/v1' } })).toEqual([ + 'baseUrlInvalid', + ]); + expect(codes({ entry: { ...complete, baseURL: '${OLLAMA_URL}' } })).toEqual([]); + expect(codes({ entry: { ...complete, baseURL: 'user_provided' } })).toEqual([]); + }); +}); + +describe('list row helpers', () => { + it('shows the host and path without the scheme, env references as written', () => { + expect(endpointHost('https://api.moonshot.ai/v1/')).toBe('api.moonshot.ai/v1'); + expect(endpointHost('https://gateway.ai.cloudflare.com/v1/${CF_ACCOUNT_ID}/x')).toBe( + 'gateway.ai.cloudflare.com/v1/${CF_ACCOUNT_ID}/x', + ); + expect(endpointHost(undefined)).toBeNull(); + }); + + it('reads model names from both model forms', () => { + expect(modelNames({ models: { default: ['a', { name: 'b', description: 'B' }] } })).toEqual([ + 'a', + 'b', + ]); + }); + + it('recognizes usable base URLs', () => { + expect(isUsableBaseURL('http://host.docker.internal:11434/v1')).toBe(true); + expect(isUsableBaseURL('ftp://x')).toBe(false); + }); +}); diff --git a/src/components/configuration/sections/endpoints/groups.ts b/src/components/configuration/sections/endpoints/groups.ts index 25b57a32..98e0779a 100644 --- a/src/components/configuration/sections/endpoints/groups.ts +++ b/src/components/configuration/sections/endpoints/groups.ts @@ -1,129 +1,154 @@ -import type { ReactNode } from 'react'; import type * as t from '@/types'; -import { renderInlineField } from '../../renderer'; /** - * Defines how fields are grouped inside custom endpoint cards and the create - * dialog. Fields not listed in any group are collected into a catch-all - * "Advanced" group (collapsed by default). - * - * To apply this pattern to other collection types, add a new entry keyed by - * the collection's schema parent key and define the groups. + * Sections of the custom endpoint editor (WS10 CE-7), by purpose. Fields + * marked `optional` are shown only when set (or added from "Add setting"), + * so an endpoint reads as what it configures rather than every option + * LibreChat has. Schema keys no section lists land in "Other settings", so + * new LibreChat fields still appear without a change here. */ -export const FIELD_GROUPS: Record = { - custom: [ - { - labelKey: 'com_config_group_connection', - fields: ['name', 'apiKey', 'baseURL', 'iconURL'], - defaultExpanded: true, - }, - { - labelKey: 'com_config_group_models', - fields: ['models', 'modelDisplayLabel'], - defaultExpanded: true, - }, - { - labelKey: 'com_config_group_title_generation', - fields: [ - 'titleConvo', - 'titleModel', - 'titleMethod', - 'titlePrompt', - 'titlePromptTemplate', - 'titleEndpoint', - 'titleMessageRole', - ], - defaultExpanded: false, - }, - { - labelKey: 'com_config_group_summarization', - fields: ['summarize', 'summaryModel'], - defaultExpanded: false, - }, - ], -}; +export const ENDPOINT_SECTIONS: t.EndpointSectionDef[] = [ + { + id: 'connection', + labelKey: 'com_config_ce_section_connection', + descriptionKey: 'com_config_ce_section_connection_desc', + fields: [ + { key: 'name' }, + { key: 'baseURL' }, + { key: 'apiKey' }, + { key: 'provider' }, + { key: 'iconURL' }, + ], + }, + { + id: 'models', + labelKey: 'com_config_ce_section_models', + descriptionKey: 'com_config_ce_section_models_desc', + fields: [ + { key: 'models.default' }, + { key: 'models.fetch' }, + { key: 'models.userIdQuery' }, + { key: 'modelDisplayLabel' }, + ], + }, + { + id: 'request', + labelKey: 'com_config_ce_section_request', + descriptionKey: 'com_config_ce_section_request_desc', + fields: [ + { key: 'headers' }, + { key: 'addParams' }, + { key: 'dropParams' }, + { key: 'directEndpoint' }, + ], + }, + { + id: 'behaviour', + labelKey: 'com_config_ce_section_behaviour', + descriptionKey: 'com_config_ce_section_behaviour_desc', + fields: [ + { key: 'titleConvo' }, + { key: 'titleModel' }, + { key: 'titleMethod' }, + { key: 'titleEndpoint', optional: true }, + { key: 'titleTiming', optional: true }, + { key: 'titlePrompt', optional: true }, + { key: 'titlePromptTemplate', optional: true }, + { key: 'titleMessageRole', optional: true }, + { key: 'streamRate', optional: true }, + { key: 'maxToolResultChars', optional: true }, + ], + }, + { + id: 'labels', + labelKey: 'com_config_ce_section_labels', + descriptionKey: 'com_config_ce_section_labels_desc', + fields: [ + { key: 'activityLabel', optional: true }, + { key: 'activityModel', optional: true }, + { key: 'activityEndpoint', optional: true }, + { key: 'activityPrompt', optional: true }, + { key: 'activityMaxPerRun', optional: true }, + { key: 'activityCharLimit', optional: true }, + { key: 'activityPhaseLabel', optional: true }, + { key: 'activityPhaseModel', optional: true }, + { key: 'activityPhaseEndpoint', optional: true }, + { key: 'activityPhasePrompt', optional: true }, + { key: 'activityPhaseMaxPerRun', optional: true }, + { key: 'reasoningLabel', optional: true }, + { key: 'reasoningLabelModel', optional: true }, + { key: 'reasoningLabelEndpoint', optional: true }, + { key: 'reasoningLabelPrompt', optional: true }, + { key: 'reasoningLabelMinChars', optional: true }, + { key: 'reasoningLabelUpdateChars', optional: true }, + { key: 'reasoningLabelUpdateIntervalMs', optional: true }, + { key: 'reasoningLabelMaxPerRun', optional: true }, + { key: 'customParams.reasoningFormat', optional: true }, + { key: 'customParams.reasoningKey', optional: true }, + { key: 'customParams.includeReasoningContent', optional: true }, + { key: 'customParams.includeReasoningHistory', optional: true }, + ], + }, + { + id: 'parameters', + labelKey: 'com_config_ce_section_parameters', + descriptionKey: 'com_config_ce_section_parameters_desc', + fields: [ + { key: 'customParams.defaultParamsEndpoint' }, + { key: 'customParams.paramDefinitions' }, + ], + }, + { + id: 'pricing', + labelKey: 'com_config_ce_section_pricing', + descriptionKey: 'com_config_ce_section_pricing_desc', + fields: [{ key: 'tokenConfig' }], + }, + { + id: 'other', + labelKey: 'com_config_ce_section_other', + fields: [], + catchAll: true, + }, +]; -/** - * Fields that are effectively required for a functional custom endpoint. - * The config schema uses `endpointSchema.partial()` which makes everything - * optional at the Zod level, but these fields are needed for an endpoint - * to actually work. We override `isOptional` for these keys so the UI - * shows required indicators. - */ -const REQUIRED_ENDPOINT_KEYS = new Set(['name', 'apiKey', 'baseURL']); +/** The sections a new endpoint starts with: what LibreChat needs to serve it. */ +export const ESSENTIAL_SECTION_IDS: ReadonlySet = new Set(['connection', 'models']); -function withRequired(field: t.SchemaField): t.SchemaField { - if (REQUIRED_ENDPOINT_KEYS.has(field.key)) { - return { ...field, isOptional: false }; - } - if (field.key === 'default' && field.path?.endsWith('models.default')) { - return { ...field, isOptional: false }; - } - return field; -} +/** Entry keys never edited directly: the stored key's masked preview travels with the key. */ +export const HIDDEN_ENDPOINT_KEYS: ReadonlySet = new Set(['apiKeyPreview']); -/** - * Flatten a field's children into the group when the field is a nested object. - * This prevents double-collapsible nesting (e.g. Models group > models collapsible). - * The children are rendered with a prefixed onChange that writes back into the - * parent object at the correct key. - */ -export function flattenGroupFields( - fields: t.SchemaField[], - parentValue: t.ConfigValue, - parentPath: string, - onChange: (path: string, value: t.ConfigValue) => void, - localize: (key: string, interpolation?: Record) => string, - disabled?: boolean, - collectionRenderOverrides?: Record, - editSessionId?: number, - lockedKeys?: ReadonlySet, -): ReactNode[] { - const values = - typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; +/** Fields that name another endpoint (titles and labels can be generated by a different one). */ +export const ENDPOINT_REFERENCE_KEYS: ReadonlySet = new Set([ + 'titleEndpoint', + 'activityEndpoint', + 'activityPhaseEndpoint', + 'reasoningLabelEndpoint', +]); + +/** Fields that name a model of this endpoint (or `current_model`). */ +export const MODEL_REFERENCE_KEYS: ReadonlySet = new Set([ + 'titleModel', + 'activityModel', + 'activityPhaseModel', + 'reasoningLabelModel', +]); + +/** Top-level entry keys the sections place (a nested key places its parent). */ +export const PLACED_TOP_KEYS: ReadonlySet = new Set( + ENDPOINT_SECTIONS.flatMap((section) => section.fields.map((field) => field.key.split('.')[0])), +); - const nodes: ReactNode[] = []; - for (const field of fields) { - if (field.children && field.children.length > 0 && !field.isArray && field.type !== 'record') { - const nested = values[field.key]; - const nestedObj = - typeof nested === 'object' && nested !== null && !Array.isArray(nested) - ? (nested as Record) - : {}; - for (const child of field.children) { - nodes.push( - renderInlineField( - withRequired(child), - nested, - `${parentPath}.${field.key}`, - (childKey, childValue) => { - onChange(field.key, { ...nestedObj, [childKey]: childValue }); - }, - localize, - disabled, - collectionRenderOverrides, - true, - editSessionId, - ), - ); - } - } else { - nodes.push( - renderInlineField( - withRequired(field), - parentValue, - parentPath, - onChange, - localize, - disabled || (lockedKeys?.has(field.key) ?? false), - collectionRenderOverrides, - true, - editSessionId, - ), - ); +/** The section an entry field (`models.default`, `customParams.paramDefinitions.0.key`) is edited in. */ +export function sectionOfField(fieldPath: string): string { + for (const section of ENDPOINT_SECTIONS) { + for (const field of section.fields) { + if (fieldPath === field.key || fieldPath.startsWith(`${field.key}.`)) return section.id; } } - return nodes; + const top = fieldPath.split('.')[0]; + const owner = ENDPOINT_SECTIONS.find((section) => + section.fields.some((field) => field.key.split('.')[0] === top), + ); + return owner?.id ?? 'other'; } diff --git a/src/components/configuration/sections/endpoints/index.ts b/src/components/configuration/sections/endpoints/index.ts index 9360fc83..547d9ef6 100644 --- a/src/components/configuration/sections/endpoints/index.ts +++ b/src/components/configuration/sections/endpoints/index.ts @@ -1,7 +1,6 @@ /** - * Custom Endpoints tab: renders the `endpoints.custom` array — each entry is - * an expandable card with field ordering and progressive disclosure, plus a - * "Create Endpoint" button that opens a dialog for building new entries - * without inline render lag. New schema fields surface automatically. + * Custom Endpoints tab (WS10): a compact list of `endpoints.custom` with an + * editor drawer per endpoint and a create flow with provider templates. New + * schema fields appear in the editor's "Other settings" automatically. */ export { CustomEndpointsRenderer } from './CustomEndpointsRenderer'; diff --git a/src/components/configuration/sections/endpoints/issues.ts b/src/components/configuration/sections/endpoints/issues.ts new file mode 100644 index 00000000..7c84176d --- /dev/null +++ b/src/components/configuration/sections/endpoints/issues.ts @@ -0,0 +1,21 @@ +import type * as t from '@/types'; + +const ISSUE_KEYS: Readonly> = { + nameRequired: 'com_config_ce_issue_name_required', + nameWhitespace: 'com_config_ce_issue_name_whitespace', + nameTaken: 'com_config_ce_issue_name_taken', + nameSimilar: 'com_config_ce_issue_name_similar', + nameReserved: 'com_config_ce_issue_name_reserved', + apiKeyRequired: 'com_config_ce_issue_api_key_required', + baseUrlRequired: 'com_config_ce_issue_base_url_required', + baseUrlInvalid: 'com_config_ce_issue_base_url_invalid', + modelsRequired: 'com_config_ce_issue_models_required', + modelBlank: 'com_config_ce_issue_model_blank', + modelDuplicate: 'com_config_ce_issue_model_duplicate', + renameNeedsKey: 'com_config_ce_issue_rename_needs_key', +}; + +/** The sentence an endpoint problem is shown as, at its field or in the list. */ +export function describeIssue(issue: t.EndpointIssue, localize: t.LocalizeFn): string { + return localize(ISSUE_KEYS[issue.code], issue.params); +} diff --git a/src/components/configuration/sections/endpoints/model.ts b/src/components/configuration/sections/endpoints/model.ts new file mode 100644 index 00000000..4c8b4b83 --- /dev/null +++ b/src/components/configuration/sections/endpoints/model.ts @@ -0,0 +1,159 @@ +/** + * Pure helpers of the custom endpoint editor (WS10): what an endpoint's list + * row shows (host, model count) and what keeps it from being saved or served + * (CE-3, CE-4). LibreChat loads a custom endpoint only with a name, an API + * 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 type * as t from '@/types'; +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_]+\}/; + +function hasText(value: t.ConfigValue): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +/** An endpoint's `models` object, or an empty one. */ +function modelsOf(entry: t.ConfigValue): t.ConfigRecord { + if (!isConfigRecord(entry)) return {}; + return isConfigRecord(entry.models) ? entry.models : {}; +} + +/** Names of the default models (a model is a name, or `{ name, description }`). */ +export function modelNames(entry: t.ConfigValue): string[] { + const list = modelsOf(entry).default; + if (!Array.isArray(list)) return []; + return list.map((item) => { + if (typeof item === 'string') return item; + if (isConfigRecord(item) && typeof item.name === 'string') return item.name; + return ''; + }); +} + +/** Whether the model list is also fetched from the provider's API. */ +export function fetchesModels(entry: t.ConfigValue): boolean { + return modelsOf(entry).fetch === true; +} + +/** + * Host (and path) a list row shows for a base URL: `api.moonshot.ai/v1`, the + * scheme and trailing slashes dropped. Environment references stay as written. + */ +export function endpointHost(baseURL: t.ConfigValue): string | null { + if (!hasText(baseURL)) return null; + return ( + baseURL + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '') || null + ); +} + +/** Whether a base URL can be used as written: an http(s) URL, an environment reference, or `user_provided`. */ +export function isUsableBaseURL(baseURL: string): boolean { + const text = baseURL.trim(); + if (text === USER_PROVIDED || ENV_PLACEHOLDER_RE.test(text)) return true; + try { + const url = new URL(text); + return (url.protocol === 'http:' || url.protocol === 'https:') && url.host !== ''; + } catch { + return false; + } +} + +/** 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)) { + return [{ field: 'name', code: 'nameReserved', severity: 'error' }]; + } + if (otherNames.includes(name)) { + return [{ field: 'name', code: 'nameTaken', severity: 'error', params: { name } }]; + } + const lower = trimmed.toLowerCase(); + const similar = otherNames.find((other) => other.trim().toLowerCase() === lower); + if (similar !== undefined) { + return [{ field: 'name', code: 'nameSimilar', severity: 'warning', params: { name: similar } }]; + } + return []; +} + +function modelIssues(entry: t.ConfigValue): t.EndpointIssue[] { + const names = modelNames(entry); + if (names.length === 0) { + return [ + { + field: 'models.default', + code: 'modelsRequired', + severity: 'error', + unserved: !fetchesModels(entry), + }, + ]; + } + const issues: t.EndpointIssue[] = []; + if (names.some((name) => name.trim() === '')) { + issues.push({ field: 'models.default', code: 'modelBlank', severity: 'error' }); + } + const seen = new Set(); + const repeated = new Set(); + for (const name of names) { + if (seen.has(name)) repeated.add(name); + seen.add(name); + } + if (repeated.size > 0) { + issues.push({ + field: 'models.default', + code: 'modelDuplicate', + severity: 'warning', + params: { models: [...repeated].join(', ') }, + }); + } + return issues; +} + +/** + * Problems of one custom endpoint, by field. A literal key librechat.yaml + * sets is redacted on read, so a key is required only of endpoints the + * target adds. A renamed endpoint whose key is stored encrypted needs the key + * again: LibreChat ties the stored key to the old name (CE-1). + */ +export function validateEndpoint(input: t.EndpointValidationInput): t.EndpointIssue[] { + const { entry, otherNames, fromLowerLayer, storedSecret, loadedName } = input; + if (!isConfigRecord(entry)) return []; + const issues: t.EndpointIssue[] = fromLowerLayer ? [] : nameIssues(entry.name, otherNames); + const renamed = loadedName !== undefined && loadedName !== entry.name; + if (storedSecret && renamed && !hasText(entry.apiKey)) { + issues.push({ field: 'apiKey', code: 'renameNeedsKey', severity: 'error' }); + } else if (!fromLowerLayer && !storedSecret && !hasText(entry.apiKey)) { + issues.push({ field: 'apiKey', code: 'apiKeyRequired', severity: 'error', unserved: true }); + } + if (!hasText(entry.baseURL)) { + issues.push({ field: 'baseURL', code: 'baseUrlRequired', severity: 'error', unserved: true }); + } else if (!isUsableBaseURL(entry.baseURL)) { + issues.push({ field: 'baseURL', code: 'baseUrlInvalid', severity: 'warning' }); + } + issues.push(...modelIssues(entry)); + return issues; +} + +/** Whether LibreChat would skip the endpoint at runtime (missing name, key, URL or models). */ +export function isUnserved(issues: readonly t.EndpointIssue[]): boolean { + return issues.some((issue) => issue.unserved === true); +} + +/** Whether any issue blocks saving. */ +export function hasBlockingIssue(issues: readonly t.EndpointIssue[]): boolean { + return issues.some((issue) => issue.severity === 'error'); +} diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts new file mode 100644 index 00000000..2b69222a --- /dev/null +++ b/src/constants/endpoints.ts @@ -0,0 +1,150 @@ +import type * as t from '@/types'; + +/** + * Starting points for a new custom endpoint (WS10 CE-10): the base URL, the + * environment variable the key is usually read from, a few models and the + * settings each provider is known to need (OpenRouter and Perplexity reject + * `stop`, Mistral rejects the OpenAI penalty and `user` parameters). Model + * lists change often: they are examples to edit, and providers that list + * their models over the API fetch them. + */ +export const ENDPOINT_TEMPLATES: readonly t.EndpointTemplate[] = [ + { + id: 'openrouter', + name: 'OpenRouter', + descriptionKey: 'com_config_ce_template_openrouter', + entry: { + name: 'OpenRouter', + apiKey: '${OPENROUTER_KEY}', + baseURL: 'https://openrouter.ai/api/v1', + models: { default: ['openai/gpt-4o-mini'], fetch: true }, + titleConvo: true, + titleModel: 'openai/gpt-4o-mini', + dropParams: ['stop'], + modelDisplayLabel: 'OpenRouter', + }, + }, + { + id: 'groq', + name: 'Groq', + descriptionKey: 'com_config_ce_template_groq', + entry: { + name: 'Groq', + apiKey: '${GROQ_API_KEY}', + baseURL: 'https://api.groq.com/openai/v1/', + models: { default: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'], fetch: true }, + titleConvo: true, + titleModel: 'llama-3.1-8b-instant', + modelDisplayLabel: 'Groq', + }, + }, + { + id: 'mistral', + name: 'Mistral', + descriptionKey: 'com_config_ce_template_mistral', + entry: { + name: 'Mistral', + apiKey: '${MISTRAL_API_KEY}', + baseURL: 'https://api.mistral.ai/v1', + models: { default: ['mistral-large-latest', 'mistral-small-latest'], fetch: true }, + titleConvo: true, + titleModel: 'mistral-small-latest', + dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty'], + modelDisplayLabel: 'Mistral', + }, + }, + { + id: 'deepseek', + name: 'DeepSeek', + descriptionKey: 'com_config_ce_template_deepseek', + entry: { + name: 'DeepSeek', + apiKey: '${DEEPSEEK_API_KEY}', + baseURL: 'https://api.deepseek.com/v1', + models: { default: ['deepseek-chat', 'deepseek-reasoner'], fetch: false }, + titleConvo: true, + titleModel: 'deepseek-chat', + modelDisplayLabel: 'DeepSeek', + }, + }, + { + id: 'xai', + name: 'xAI', + descriptionKey: 'com_config_ce_template_xai', + entry: { + name: 'xAI', + apiKey: '${XAI_API_KEY}', + baseURL: 'https://api.x.ai/v1', + models: { default: ['grok-3', 'grok-3-mini'], fetch: true }, + titleConvo: true, + titleModel: 'grok-3-mini', + modelDisplayLabel: 'Grok', + }, + }, + { + id: 'together', + name: 'Together AI', + descriptionKey: 'com_config_ce_template_together', + entry: { + name: 'Together AI', + apiKey: '${TOGETHERAI_API_KEY}', + baseURL: 'https://api.together.xyz/v1', + models: { default: ['meta-llama/Llama-3.3-70B-Instruct-Turbo'], fetch: true }, + titleConvo: true, + titleModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + modelDisplayLabel: 'Together AI', + }, + }, + { + id: 'perplexity', + name: 'Perplexity', + descriptionKey: 'com_config_ce_template_perplexity', + entry: { + name: 'Perplexity', + apiKey: '${PERPLEXITY_API_KEY}', + baseURL: 'https://api.perplexity.ai/', + models: { default: ['sonar', 'sonar-pro'], fetch: false }, + titleConvo: true, + titleModel: 'sonar', + dropParams: ['stop', 'frequency_penalty'], + modelDisplayLabel: 'Perplexity', + }, + }, + { + id: 'fireworks', + name: 'Fireworks', + descriptionKey: 'com_config_ce_template_fireworks', + entry: { + name: 'Fireworks', + apiKey: '${FIREWORKS_API_KEY}', + baseURL: 'https://api.fireworks.ai/inference/v1', + models: { default: ['accounts/fireworks/models/llama-v3p3-70b-instruct'], fetch: true }, + titleConvo: true, + titleModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct', + dropParams: ['user'], + modelDisplayLabel: 'Fireworks', + }, + }, + { + id: 'ollama', + name: 'Ollama', + descriptionKey: 'com_config_ce_template_ollama', + entry: { + name: 'Ollama', + apiKey: 'ollama', + baseURL: 'http://host.docker.internal:11434/v1/', + models: { default: ['llama3.2'], fetch: true }, + titleConvo: true, + titleModel: 'current_model', + modelDisplayLabel: 'Ollama', + }, + }, +]; + +/** A blank OpenAI-compatible endpoint. */ +export const BLANK_ENDPOINT_TEMPLATE: t.EndpointTemplate = { + id: 'blank', + name: '', + descriptionKey: 'com_config_ce_template_blank', + entry: {}, +}; diff --git a/src/constants/index.ts b/src/constants/index.ts index 4f642a75..c57b58e0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -5,3 +5,4 @@ export * from './scope'; export * from './config'; export * from './collections'; export * from './providers'; +export * from './endpoints'; diff --git a/src/contexts/CollectionEditsContext.ts b/src/contexts/CollectionEditsContext.ts new file mode 100644 index 00000000..e8aef13d --- /dev/null +++ b/src/contexts/CollectionEditsContext.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; +import type * as t from '@/types'; + +const NO_COLLECTION_EDITS: t.CollectionEdits = {}; + +/** + * The page's pending keyed-collection edits (WS1 `collectionEdits`): which + * loaded entry each pending item came from, and the loaded entries a pending + * edit took out. Collection editors read it to show pending deletions (with + * Undo) and renames from the edit state itself, so they survive Discard → + * Undo and edits made from outside the editor (WS10). + */ +export const CollectionEditsContext = createContext(NO_COLLECTION_EDITS); diff --git a/src/contexts/EntryFieldChipsContext.ts b/src/contexts/EntryFieldChipsContext.ts new file mode 100644 index 00000000..4096c48f --- /dev/null +++ b/src/contexts/EntryFieldChipsContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import type * as t from '@/types'; + +/** + * Extra chips for the field rows of a collection entry (Edited with Undo, + * an admin override with Revert), provided by a collection editor such as + * the custom endpoint editor (WS10). Null: entry rows show no such chips. + */ +export const EntryFieldChipsContext = createContext(null); diff --git a/src/hooks/config/endpoints.ts b/src/hooks/config/endpoints.ts new file mode 100644 index 00000000..fc679669 --- /dev/null +++ b/src/hooks/config/endpoints.ts @@ -0,0 +1,139 @@ +import { useContext, useMemo } from 'react'; +import type * as t from '@/types'; +import { + getEntryKey, + isConfigRecord, + getValueAtPath, + hasHiddenSecret, + isConfigValueEqual, + getKeyedCollection, + overriddenEntryKeys, +} from '@/utils'; +import { + endpointHost, + fetchesModels, + modelNames, + validateEndpoint, +} from '@/components/configuration/sections/endpoints/model'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { getEntryTitle } from '@/components/configuration/fields/entries'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { useLocalize } from '../useLocalize'; +import { useListKeys } from './keys'; + +const NAME_KEY = 'name'; + +function nameOf(item: t.ConfigValue): string | null { + return getEntryKey(item, NAME_KEY) ?? null; +} + +function byName(list: t.ConfigValue): Map { + const map = new Map(); + if (!Array.isArray(list)) return map; + for (const entry of list) { + const name = getEntryKey(entry, NAME_KEY); + if (name !== undefined && isConfigRecord(entry)) map.set(name, entry); + } + return map; +} + +/** + * The custom endpoint list of the open target as the endpoint editor shows + * it (WS10): per row where the endpoint comes from (librechat.yaml, the admin + * panel, Base or the profile), whether the target overrides it, whether it is + * new or edited, and what keeps it from being saved or served; plus the + * saved endpoints a pending edit removes. Renames and removals are read from + * the page's pending collection edits, so they survive Discard → Undo. + */ +export function useEndpointRows({ path, items }: t.EndpointRowsOptions): t.EndpointRows { + const localize = useLocalize(); + const layers = useContext(ConfigValuesContext)?.layers ?? null; + const edit = useContext(CollectionEditsContext)[path]; + const keys = useListKeys({ items, identity: nameOf, prefix: 'endpoint' }); + + const context = useMemo(() => { + const collection = getKeyedCollection(path); + const lowerRoot = layers?.scope ? layers.base : (layers?.yamlEffective ?? null); + const storedRoot = layers?.scope ? layers.scope.overrides : (layers?.baseOverrides ?? null); + const lowerList = getValueAtPath(lowerRoot, path); + const storedList = getValueAtPath(storedRoot, path); + return { + collection, + lower: byName(lowerList), + stored: byName(storedList), + saved: byName(getValueAtPath(layers?.effective ?? null, path)), + overridden: collection + ? overriddenEntryKeys(collection, storedList, lowerList) + : new Set(), + isScope: layers?.scope != null, + }; + }, [layers, path]); + + return useMemo((): t.EndpointRows => { + const { collection, lower, stored, saved, overridden, isScope } = context; + const names = items.map((item) => getEntryKey(item, NAME_KEY) ?? ''); + const rows = items.map((item, index): t.EndpointRow => { + const value = isConfigRecord(item) ? item : {}; + const identity = getEntryKey(item, NAME_KEY); + const origin = edit ? edit.origins[index] : undefined; + const isNew = edit != null && origin === null; + const loadedName = isNew ? undefined : (origin?.identity ?? identity); + const fromLower = loadedName !== undefined && lower.has(loadedName); + const storedEntry = loadedName === undefined ? undefined : stored.get(loadedName); + const storedSecret = collection + ? hasHiddenSecret(storedEntry, collection) || hasHiddenSecret(value, collection) + : false; + const savedEntry = loadedName === undefined ? undefined : saved.get(loadedName); + let entryOrigin: t.EndpointOrigin = isScope ? 'scope' : 'admin'; + if (fromLower) entryOrigin = isScope ? 'inherited' : 'yaml'; + return { + key: keys[index], + index, + value, + title: getEntryTitle(item, index, localize), + identity, + loadedName, + origin: entryOrigin, + overridden: fromLower && loadedName !== undefined && overridden.has(loadedName), + isNew, + edited: !isNew && savedEntry !== undefined && !isConfigValueEqual(savedEntry, item), + saved: savedEntry, + host: endpointHost(value.baseURL), + modelCount: modelNames(value).length, + fetchesModels: fetchesModels(value), + storedSecret, + issues: validateEndpoint({ + entry: value, + otherNames: names.filter((_, i) => i !== index), + fromLowerLayer: fromLower, + storedSecret, + loadedName, + }), + }; + }); + const baselineIndex = new Map( + (edit?.baseline ?? []).map((entry, index) => [getEntryKey(entry, NAME_KEY), index]), + ); + const pendingOrigins = items.map((_, index) => { + const identity = edit?.origins[index]?.identity; + return identity === undefined ? Infinity : (baselineIndex.get(identity) ?? Infinity); + }); + const removals = (edit?.detached ?? []).map((detached): t.EndpointRemoval => { + const at = baselineIndex.get(detached.origin.identity); + const index = at ?? items.length; + const before = pendingOrigins.findIndex((origin) => origin > index); + return { + key: `removed-${detached.origin.identity}`, + title: getEntryTitle(detached.value, at ?? 0, localize), + value: detached.value, + index, + restoreIndex: before === -1 ? items.length : before, + }; + }); + return { + rows, + removals, + lowerEntry: (name) => lower.get(name), + }; + }, [context, items, edit, keys, localize]); +} diff --git a/src/hooks/config/index.ts b/src/hooks/config/index.ts index 22d2aab5..483f4f5a 100644 --- a/src/hooks/config/index.ts +++ b/src/hooks/config/index.ts @@ -2,6 +2,7 @@ export * from './baseline'; export * from './counts'; export * from './draft'; export * from './edits'; +export * from './endpoints'; export * from './expand'; export * from './field'; export * from './guard'; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a1e1ada8..1c8dc00e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -76,6 +76,205 @@ "com_config_endpoint_name_reserved": "This name belongs to a built-in LibreChat endpoint; choose another name", "com_config_endpoint_name_required": "Endpoint name is required", "com_config_endpoint_required_missing": "Still needed: {{fields}}", + "com_config_ce_action_delete": "Delete endpoint", + "com_config_ce_action_edit": "Edit", + "com_config_ce_action_revert": "Revert to librechat.yaml", + "com_config_ce_action_view": "View", + "com_config_ce_actions_a11y": "Actions for {{name}}", + "com_config_ce_add_setting": "Add a setting…", + "com_config_ce_add_setting_a11y": "Add a {{section}} setting", + "com_config_ce_back": "Back", + "com_config_ce_create": "Create endpoint", + "com_config_ce_create_form_desc": "Fill in what LibreChat needs to serve it. Everything else can be set after it is created.", + "com_config_ce_create_inherited": "{{scope}} already gets \"{{name}}\" from Base. To change it for {{scope}}, override it instead of creating a second endpoint with the same name.", + "com_config_ce_create_more": "Titles, headers, parameters and pricing can be set in the editor once the endpoint is created. Nothing is saved until you review and save your changes.", + "com_config_ce_create_pick_desc": "Start from a provider, or from a blank OpenAI-compatible endpoint.", + "com_config_ce_current_model": "Current conversation model", + "com_config_ce_current_model_note": "Uses whichever model the conversation is on.", + "com_config_ce_delete_locked": "Defined in librechat.yaml: it can be changed here, but only removed from librechat.yaml", + "com_config_ce_delete_locked_inherited": "Comes from Base: it can be overridden in this profile, not removed", + "com_config_ce_discard": "Discard", + "com_config_ce_discard_confirm": "Discard this new endpoint?", + "com_config_ce_done": "Done", + "com_config_ce_drawer_desc": "Custom endpoint", + "com_config_ce_drawer_staged": "Changes are kept until you review and save", + "com_config_ce_empty_scope": "No custom endpoints yet. Create one to add it for this profile only.", + "com_config_ce_field_override_desc": "Set in the admin panel; replaces the librechat.yaml value", + "com_config_ce_field_override_scope_desc": "Set in this profile; replaces the value it inherits from Base", + "com_config_ce_filter_a11y": "Filter endpoints", + "com_config_ce_filter_empty": "No endpoint matches \"{{query}}\".", + "com_config_ce_filter_placeholder": "Filter by name, URL or model", + "com_config_ce_issue_api_key_required": "Enter an API key, an environment variable such as ${PROVIDER_API_KEY}, or user_provided to let each user enter their own.", + "com_config_ce_issue_base_url_invalid": "This doesn't look like a URL. Use https://…, an environment variable such as ${PROVIDER_URL}, or user_provided.", + "com_config_ce_issue_base_url_required": "Enter the base URL of the provider's API.", + "com_config_ce_issue_model_blank": "Remove the blank model, or give it a name.", + "com_config_ce_issue_model_duplicate": "Listed more than once: {{models}}. Users see each copy in the model menu.", + "com_config_ce_issue_models_required": "Add at least one model. LibreChat requires a default list even when it fetches models, and uses it if fetching fails.", + "com_config_ce_issue_name_required": "Enter a name.", + "com_config_ce_issue_name_reserved": "This name belongs to a built-in LibreChat endpoint. Choose another name.", + "com_config_ce_issue_name_similar": "Another endpoint is named \"{{name}}\". Names that differ only in letter case are easy to confuse.", + "com_config_ce_issue_name_taken": "Another endpoint is already named \"{{name}}\".", + "com_config_ce_issue_name_whitespace": "Remove the space at the start or end of the name.", + "com_config_ce_issue_rename_needs_key": "Enter the API key again: the stored key belongs to the old name and can't move to the new one.", + "com_config_ce_keep_editing": "Keep editing", + "com_config_ce_key_hidden": "Not shown", + "com_config_ce_key_hidden_note": "librechat.yaml may set this key as plain text, which LibreChat never sends back. Replace it to store a new key here.", + "com_config_ce_key_placeholder": "New key, ${ENV_VAR} or user_provided", + "com_config_ce_key_replace": "Replace", + "com_config_ce_list_label": "Custom endpoints", + "com_config_ce_model_default": "Default (the conversation's model)", + "com_config_ce_model_placeholder": "Model id or current_model", + "com_config_ce_models_count_one": "{{count}} model", + "com_config_ce_models_count_other": "{{count}} models", + "com_config_ce_models_fetched": "also fetched", + "com_config_ce_name_locked": "Defined in librechat.yaml, so it can't be renamed here.", + "com_config_ce_name_locked_inherited": "Comes from Base, so it can't be renamed in this profile.", + "com_config_ce_name_placeholder": "Shown in the model menu, e.g. OpenRouter", + "com_config_ce_no_base_url": "No base URL", + "com_config_ce_none_set_in_scope": "{{scope}} doesn't change any custom endpoint.", + "com_config_ce_notice_inherited": "Inherited from Base. Users in {{scope}} get it as Base defines it.", + "com_config_ce_notice_inherited_override": "{{scope}} overrides this Base endpoint. Only the settings you change here are saved for {{scope}}.", + "com_config_ce_notice_yaml": "Defined in librechat.yaml. Changes here override its settings; it can't be renamed or deleted here.", + "com_config_ce_notice_yaml_overridden": "Defined in librechat.yaml, with settings overridden in the admin panel. It can't be renamed or deleted here.", + "com_config_ce_override_for": "Override for {{scope}}", + "com_config_ce_override_named": "Override {{name}}", + "com_config_ce_params_default": "Default (OpenAI-compatible)", + "com_config_ce_params_openai_compatible": "OpenAI-compatible", + "com_config_ce_ref_default_endpoint": "Default (this endpoint)", + "com_config_ce_remove_scope_overrides": "Remove {{scope}} overrides", + "com_config_ce_section_behaviour": "Behaviour", + "com_config_ce_section_behaviour_desc": "Conversation titles, streaming and tool output.", + "com_config_ce_section_connection": "Connection", + "com_config_ce_section_connection_desc": "Where requests go and how they authenticate.", + "com_config_ce_section_errors_one": "{{count}} problem", + "com_config_ce_section_errors_other": "{{count}} problems", + "com_config_ce_section_labels": "Labels & reasoning", + "com_config_ce_section_labels_desc": "Progress labels while the model works, and how reasoning is read from responses.", + "com_config_ce_section_models": "Models", + "com_config_ce_section_models_desc": "The models users can pick for this endpoint.", + "com_config_ce_section_nothing_set": "Nothing set.", + "com_config_ce_section_other": "Other settings", + "com_config_ce_section_parameters": "Parameters", + "com_config_ce_section_parameters_desc": "The parameter panel users see, and defaults for its parameters.", + "com_config_ce_section_pricing": "Token pricing", + "com_config_ce_section_pricing_desc": "Context window and per-million-token rates used for balances and usage.", + "com_config_ce_section_request": "Request shaping", + "com_config_ce_section_request_desc": "Headers and parameters added to or removed from every request.", + "com_config_ce_sections_nav": "Endpoint settings", + "com_config_ce_source_admin": "Added in admin", + "com_config_ce_source_admin_desc": "Created in the admin panel; librechat.yaml doesn't define it", + "com_config_ce_source_inherited": "Inherited from Base", + "com_config_ce_source_inherited_desc": "Defined in the base configuration", + "com_config_ce_source_inherited_overrides": "Base + {{scope}}", + "com_config_ce_source_inherited_overrides_desc": "Defined in the base configuration, with settings overridden for {{scope}}", + "com_config_ce_source_scope": "Added in {{scope}}", + "com_config_ce_source_scope_desc": "Only {{scope}} has this endpoint", + "com_config_ce_source_yaml": "librechat.yaml", + "com_config_ce_source_yaml_desc": "Defined in librechat.yaml", + "com_config_ce_source_yaml_overrides": "librechat.yaml + overrides", + "com_config_ce_source_yaml_overrides_desc": "Defined in librechat.yaml, with settings overridden in the admin panel", + "com_config_ce_state_new": "New", + "com_config_ce_state_new_desc": "Added since the last save", + "com_config_ce_status_invalid": "Needs a fix", + "com_config_ce_status_unserved": "Incomplete – not served", + "com_config_ce_template_blank": "Any OpenAI-compatible API: enter its URL, key and models yourself.", + "com_config_ce_template_blank_name": "Blank endpoint", + "com_config_ce_template_deepseek": "DeepSeek chat and reasoner models.", + "com_config_ce_template_fireworks": "Open models hosted by Fireworks AI.", + "com_config_ce_template_groq": "Fast inference for open models on Groq.", + "com_config_ce_template_heading": "Start from", + "com_config_ce_template_mistral": "Mistral's models; drops the parameters Mistral rejects.", + "com_config_ce_template_ollama": "Models running on your own Ollama server.", + "com_config_ce_template_openrouter": "Hundreds of models through one key; the model list is fetched.", + "com_config_ce_template_perplexity": "Sonar search models; drops the parameters Perplexity rejects.", + "com_config_ce_template_together": "Open models hosted by Together AI.", + "com_config_ce_template_xai": "Grok models from xAI.", + "com_config_ce_param_add_builtin": "Override a built-in parameter…", + "com_config_ce_param_add_custom": "Add a custom parameter", + "com_config_ce_param_builtin_default": "Built-in default: {{value}}", + "com_config_ce_param_builtin_note": "LibreChat shows its own translated text ({{code}}).", + "com_config_ce_param_builtin_text": "Built-in text", + "com_config_ce_param_component_auto": "Default for the type", + "com_config_ce_param_component_checkbox": "Checkbox", + "com_config_ce_param_component_combobox": "Searchable list", + "com_config_ce_param_component_dropdown": "Dropdown", + "com_config_ce_param_component_input": "Text input", + "com_config_ce_param_component_slider": "Slider", + "com_config_ce_param_component_switch": "Switch", + "com_config_ce_param_component_tags": "Tags", + "com_config_ce_param_component_textarea": "Text area", + "com_config_ce_param_custom_text": "Use custom text", + "com_config_ce_param_customize": "Customize every option", + "com_config_ce_param_default_summary": "default {{value}}", + "com_config_ce_param_key_locked": "Defined in librechat.yaml, so its key can't change here.", + "com_config_ce_param_no_default": "No default", + "com_config_ce_param_override_note": "Changes the default of a built-in parameter; everything else stays as LibreChat defines it.", + "com_config_ce_param_pick_type": "Pick a type first", + "com_config_ce_param_type_array": "List of values", + "com_config_ce_param_type_boolean": "On / off", + "com_config_ce_param_type_enum": "One of a set of options", + "com_config_ce_param_type_number": "Number", + "com_config_ce_param_type_string": "Text", + "com_config_ce_param_type_unset": "Not set", + "com_config_ce_param_untitled": "Parameter {{n}}", + "com_config_ce_params_desc": "Parameters users can adjust for this endpoint, and their defaults.", + "com_config_ce_params_empty": "Users get the parameters of the parameter set above, with its defaults.", + "com_config_ce_drop_params_none": "None", + "com_config_ce_drop_params_placeholder": "Add a parameter, e.g. stop", + "com_config_ce_placeholder_copied": "Copied {{value}}. Paste it into a header value.", + "com_config_ce_placeholder_insert": "Insert at the cursor of the header value you are editing", + "com_config_ce_placeholders": "Placeholders:", + "com_config_ce_revert": "Revert", + "com_config_ce_pricing_add": "Add model", + "com_config_ce_pricing_add_placeholder": "Model to price, e.g. gpt-4o-mini", + "com_config_ce_pricing_cache_read": "Cache read", + "com_config_ce_pricing_cache_write": "Cache write", + "com_config_ce_pricing_completion": "Output", + "com_config_ce_pricing_context": "Context (tokens)", + "com_config_ce_pricing_desc": "Rates in USD per million tokens. Models without a row use LibreChat's own rates, if it knows the model.", + "com_config_ce_pricing_empty": "No model has its own pricing.", + "com_config_ce_pricing_missing": "Enter input, output and context.", + "com_config_ce_pricing_model": "Model", + "com_config_ce_pricing_prompt": "Input", + "com_config_ce_pricing_rates": "Model rates", + "com_config_ce_provider_anthropic": "Use the Anthropic Messages API", + "com_config_desc_endpoints_custom_activityPhaseEndpoint": "Endpoint whose credentials run the phase summary model.", + "com_config_desc_endpoints_custom_activityPhaseModel": "Model that writes phase summaries. Defaults to the activity model.", + "com_config_desc_endpoints_custom_activityPhasePrompt": "Replaces the instructions used to write phase summaries.", + "com_config_desc_endpoints_custom_customParams_includeReasoningContent": "Send the model's reasoning back within a run's tool-call turns, as models such as Kimi and Xiaomi MiMo require.", + "com_config_desc_endpoints_custom_customParams_includeReasoningHistory": "Also rebuild reasoning from earlier turns of the conversation. Implies Include reasoning content.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_columnSpan": "How many columns of the parameter panel the control spans.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_columns": "How many columns the options are laid out in.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_component": "The control users see in the parameter panel.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_default": "Value used until a user changes it.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_description": "Help shown with the parameter.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_enumMappings": "Value sent to the provider for each option.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_includeInput": "Show a number input next to the slider.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_key": "The parameter's name in requests, for example temperature. Using a built-in parameter's name changes that parameter.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_label": "Name shown in the parameter panel.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_maxTags": "Most values allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_maxText": "Longest text allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_minTags": "Fewest values allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_minText": "Shortest text allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_optionType": "Where the value is kept: with the conversation, with the model, or as a custom option.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_options": "The choices users can pick from.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_placeholder": "Text shown in the empty input.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_range": "Lowest and highest value, and the step between values.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_searchPlaceholder": "Text shown in the search box.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_selectPlaceholder": "Text shown before an option is picked.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_showDefault": "Show the default value next to the label.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_showLabel": "Show the label above the control.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_type": "Kind of value: a number, on/off, text, one of a set of options, or a list.", + "com_config_desc_endpoints_custom_customParams_reasoningFormat": "How the reasoning effort is sent to the provider: as reasoning_effort, as a reasoning object, or not at all.", + "com_config_desc_endpoints_custom_customParams_reasoningKey": "The response field the model's reasoning text is read from.", + "com_config_desc_endpoints_custom_directEndpoint": "Send requests to the base URL exactly as written, without adding /chat/completions.", + "com_config_desc_endpoints_custom_reasoningLabelEndpoint": "Endpoint whose credentials run the reasoning label model.", + "com_config_desc_endpoints_custom_reasoningLabelMinChars": "Characters of reasoning before the first label is written.", + "com_config_desc_endpoints_custom_reasoningLabelPrompt": "Replaces the instructions used to write reasoning labels.", + "com_config_desc_endpoints_custom_reasoningLabelUpdateChars": "New reasoning characters needed before the label is rewritten.", + "com_config_desc_endpoints_custom_reasoningLabelUpdateIntervalMs": "Shortest time between two label updates.", + "com_config_desc_endpoints_custom_titleMessageRole": "Role of the message that carries the title instructions.", + "com_config_issue_name_whitespace": "starts or ends with a space", "com_config_create_mcp_server": "Create MCP server", "com_config_server_name": "Server name", "com_config_server_name_required": "Server name is required", diff --git a/src/types/config.ts b/src/types/config.ts index ad791378..95d9a61b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -163,6 +163,9 @@ export interface SelectOption { export type KVValueType = 'string' | 'number' | 'boolean' | 'json'; +/** The half of a key/value pair an input edits. */ +export type KVPart = 'key' | 'value'; + export interface KeyValuePair { [k: string]: string | KVValueType | undefined; key: string; diff --git a/src/types/endpoints.ts b/src/types/endpoints.ts new file mode 100644 index 00000000..4f418b8d --- /dev/null +++ b/src/types/endpoints.ts @@ -0,0 +1,306 @@ +import type { SettingDefinition } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type { ConfigRecord, ConfigValue, SchemaField } from './config'; + +/** + * Where a custom endpoint comes from, seen from the edit target (WS10): in + * Base, librechat.yaml (`yaml`) or the admin panel (`admin`); in a profile, + * Base (`inherited`) or the profile itself (`scope`). + */ +export type EndpointOrigin = 'yaml' | 'admin' | 'inherited' | 'scope'; + +/** What is wrong with a custom endpoint, by field (WS10 CE-3/CE-4). */ +export type EndpointIssueCode = + | 'nameRequired' + | 'nameWhitespace' + | 'nameTaken' + | 'nameSimilar' + | 'nameReserved' + | 'apiKeyRequired' + | 'baseUrlRequired' + | 'baseUrlInvalid' + | 'modelsRequired' + | 'modelBlank' + | 'modelDuplicate' + | 'renameNeedsKey'; + +export interface EndpointIssue { + /** Field inside the entry (`name`, `models.default`). */ + field: string; + code: EndpointIssueCode; + /** `error` blocks the save; `warning` is shown but saves. */ + severity: 'error' | 'warning'; + /** LibreChat skips the endpoint at runtime while this is unresolved ("Incomplete – not served"). */ + unserved?: boolean; + params?: Record; +} + +/** Inputs of `validateEndpoint`. */ +export interface EndpointValidationInput { + entry: ConfigValue; + /** Names of the other endpoints in the list (pending), to catch duplicates. */ + otherNames: readonly string[]; + /** The layer beneath defines this endpoint (its literal key is redacted on read). */ + fromLowerLayer: boolean; + /** The target stores an encrypted key for it, readable only as a preview. */ + storedSecret: boolean; + /** The name the entry was loaded with, when it was renamed since. */ + loadedName?: string; +} + +/** One custom endpoint as the list shows it (WS10 CE-7). */ +export interface EndpointRow { + /** Stable React key across edits, renames and removals. */ + key: string; + /** Position in the pending list. */ + index: number; + value: ConfigRecord; + title: string; + /** The entry's name, when it has one. */ + identity?: string; + /** The name it was loaded with (differs after a pending rename); undefined for a new entry. */ + loadedName?: string; + origin: EndpointOrigin; + /** The target stores its own override of an endpoint the layer beneath defines. */ + overridden: boolean; + /** Added since the page loaded (not saved yet). */ + isNew: boolean; + /** Differs from its saved value. */ + edited: boolean; + /** The endpoint as saved (what Undo goes back to); undefined for a new one. */ + saved?: ConfigRecord; + host: string | null; + modelCount: number; + fetchesModels: boolean; + /** An encrypted key is stored for it; the read shows only its preview. */ + storedSecret: boolean; + issues: EndpointIssue[]; +} + +/** A saved endpoint a pending edit removes; Undo puts it back at `index`. */ +export interface EndpointRemoval { + key: string; + title: string; + value: ConfigValue; + /** Position in the collection as loaded. */ + index: number; + /** Where Undo puts it back in the pending list: before the first surviving entry loaded after it. */ + restoreIndex: number; +} + +/** The custom endpoint list of the open target, with pending edits (WS10). */ +export interface EndpointRows { + rows: EndpointRow[]; + removals: EndpointRemoval[]; + /** The value of an endpoint in the layer beneath (librechat.yaml, or Base in a profile). */ + lowerEntry: (name: string) => ConfigRecord | undefined; +} + +export interface EndpointRowsOptions { + /** `endpoints.custom` */ + path: string; + items: ConfigValue[]; +} + +/** A field of the endpoint editor: its key inside the entry, which may be nested (`models.default`). */ +export interface EndpointFieldDef { + key: string; + /** Shown only when set, or once added from "Add setting". */ + optional?: boolean; +} + +/** One section of the endpoint editor (WS10 CE-7). */ +export interface EndpointSectionDef { + id: string; + labelKey: string; + descriptionKey?: string; + fields: EndpointFieldDef[]; + /** Unknown and remaining schema keys land here. */ + catchAll?: boolean; +} + +export interface EndpointDrawerProps { + open: boolean; + onClose: () => void; + title: string; + /** Chips beside the title (source, state). */ + badges?: ReactNode; + /** Short line under the title (base URL host, origin). */ + subtitle?: ReactNode; + /** Shown above the body, below the header (banners). */ + notice?: ReactNode; + footer: ReactNode; + children: ReactNode; + /** Close requests (Esc, overlay, ×) go here instead, e.g. to confirm discarding a draft. */ + onRequestClose?: () => void; + bodyRef?: React.Ref; + /** The element focus goes back to on close (there is no Radix trigger). */ + returnFocus?: () => HTMLElement | null; +} + +export interface EndpointEditorProps { + /** Data path of the entry (`endpoints.custom.3`), or the draft's while creating. */ + path: string; + fields: SchemaField[]; + value: ConfigRecord; + /** Writes one key of the entry; `undefined` removes it. */ + onFieldChange: (key: string, value: ConfigValue) => void; + issues: EndpointIssue[]; + origin: EndpointOrigin; + disabled?: boolean; + /** Names of the endpoints in the list, for the endpoint pickers. */ + endpointNames: readonly string[]; + storedSecret: boolean; + /** The endpoint as the layer beneath defines it (librechat.yaml, or Base in a profile). */ + lowerEntry?: ConfigRecord; + /** The endpoint as saved, which a field's Undo goes back to. */ + savedEntry?: ConfigRecord; + /** Default models of another custom endpoint, for model pickers that name it. */ + modelsOf?: (endpoint: string) => readonly string[]; + /** Only the essentials (creating): Connection and Models. */ + essentialsOnly?: boolean; + /** Field rows carry Edited/Undo and override/Revert chips (saved endpoints). */ + showChips?: boolean; + /** Entry field to bring into view (`baseURL`, `customParams.paramDefinitions.0`). */ + revealField?: string; + /** Called with the full path once `revealField` is rendered, to scroll to and focus it. */ + onRevealed?: (path: string) => void; + editSessionId?: number; +} + +/** A starting point offered when creating a custom endpoint (WS10 CE-10). */ +export interface EndpointTemplate { + id: string; + /** Display name, also the suggested endpoint name. */ + name: string; + descriptionKey: string; + /** The entry the template starts from (name included). */ + entry: ConfigRecord; +} + +/** Props of the chips an editor row carries: unsaved change with Undo, admin override with Revert. */ +export interface EntryFieldChipsProps { + /** Data path of the field (`endpoints.custom.3.baseURL`). */ + path: string; + label: string; + /** The value shown in the control (the pending one). */ + value: ConfigValue; + /** Writes the field (Undo puts the saved value back). */ + onChange: (value: ConfigValue) => void; + /** Goes back to the value of the layer beneath (default: writes that value). */ + onRevert?: () => boolean | void; + disabled?: boolean; +} + +/** Props of the endpoint editor's field chips. */ +export interface FieldChipsProps extends EntryFieldChipsProps { + /** The field's saved value: the pending one differs from it while Edited. */ + saved: ConfigValue; + /** The endpoint comes from the layer beneath, so a value it sets can be an override. */ + showOverride: boolean; +} + +/** Renders the extra chips of an entry field row, provided by a collection editor (WS10). */ +export type EntryFieldChipsRenderer = (props: EntryFieldChipsProps) => ReactNode; + +/** Tone of a notice in the endpoint editor. */ +export type EndpointBannerTone = 'info' | 'warning' | 'danger' | 'neutral'; + +/** Props of the custom endpoint list (WS10 CE-7). */ +export interface EndpointListProps { + rows: EndpointRow[]; + removals: EndpointRemoval[]; + /** Read-only: rows open the editor for viewing; no revert or delete. */ + disabled?: boolean; + /** The row whose editor is open. */ + openKey?: string | null; + onOpen: (row: EndpointRow) => void; + /** A reveal (table of contents, search, review) asks for the row: open its editor, keeping any field target. */ + onReveal: (row: EndpointRow) => void; + onDelete: (row: EndpointRow) => void; + /** Reverts every override of the endpoint (to librechat.yaml, or to Base in a profile). */ + onRevert: (row: EndpointRow) => void; + onUndoRemoval: (removal: EndpointRemoval) => void; + /** Row ids are `${idPrefix}-${index}`, the ids the table of contents and deep links target. */ + idPrefix: string; +} + +/** Props of the create flow (WS10 CE-3, CE-10). */ +export interface EndpointCreatorProps { + open: boolean; + /** Schema fields of a custom endpoint entry. */ + fields: SchemaField[]; + /** Names of the endpoints in the list (pending), to keep the new name unique. */ + names: readonly string[]; + /** In a profile: names Base defines, which are overridden rather than created (CE-8). */ + inheritedNames: readonly string[]; + onCreate: (entry: ConfigRecord) => void; + /** Opens the inherited endpoint `name` to override it instead. */ + onOverride: (name: string) => void; + onClose: () => void; + /** Where focus goes when the flow closes without creating (the Create button). */ + returnFocus?: () => HTMLElement | null; +} + +/** Props of an endpoint's parameter definitions (WS10 CE-6). */ +export interface ParamDefinitionsRowProps { + /** Data path of the list (`endpoints.custom.3.customParams.paramDefinitions`). */ + path: string; + field: SchemaField; + value: ConfigValue; + /** Keys of the definitions the layer beneath defines: they keep their key and can't be removed. */ + lowerKeys: ReadonlySet; + /** The parameter set whose built-in parameters can be overridden (`custom`, `anthropic`, …). */ + paramSet?: string; + onChange: (value: ConfigValue) => void; + disabled?: boolean; + editSessionId?: number; + chips?: ReactNode; +} + +/** Props of one parameter definition's fields (WS10 CE-6). */ +export interface ParamDefinitionEntryProps { + fields: SchemaField[]; + value: ConfigRecord; + /** Data path of the definition. */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + disabled?: boolean; + builtIns: Map; + /** The layer beneath defines this definition: its key can't change. */ + keyLocked: boolean; +} + +/** Props of a list of names edited as tags (`dropParams`). */ +export interface TagListFieldProps { + id: string; + values: string[]; + onChange: (values: string[]) => void; + /** Called instead of emitting an empty list when the last tag is removed. */ + onClear: () => void; + disabled?: boolean; + 'aria-label'?: string; +} + +/** Props of an endpoint's token pricing table (WS10 CE-7). */ +export interface PricingRowProps { + /** Data path of `tokenConfig`. */ + path: string; + value: ConfigValue; + /** The endpoint's models, suggested when adding a row. */ + models: readonly string[]; + /** Models the layer beneath prices: their rows can change but not be removed. */ + lockedModels: ReadonlySet; + onChange: (value: ConfigValue) => void; + disabled?: boolean; + chips?: ReactNode; +} + +/** Props of one row of the endpoint list. */ +export interface EndpointListRowProps extends Pick< + EndpointListProps, + 'idPrefix' | 'disabled' | 'onOpen' | 'onReveal' | 'onDelete' | 'onRevert' +> { + row: EndpointRow; + isOpen: boolean; +} diff --git a/src/types/index.ts b/src/types/index.ts index 5452a79c..f66b369f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export type * from './auth'; export type * from './changes'; export type * from './config'; export type * from './config-ui'; +export type * from './endpoints'; export type * from './controls'; export type * from './fields'; export type * from './grant'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 3e301c60..d978963f 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -104,6 +104,7 @@ const ISSUE_MESSAGES: Readonly> = { invalid_document: 'is not a valid configuration', invalid_union: 'is not a valid value', models_required: 'needs at least one model', + name_whitespace: 'starts or ends with a space', YAML_MANAGED: 'is managed in librechat.yaml; LibreChat ignores admin overrides for it until it re-processes them', PROCESS_MCP_FIELD: @@ -254,7 +255,9 @@ export function validateLoadableEndpoint( }); const models = isConfigRecord(entry.models) ? entry.models : {}; const hasModels = Array.isArray(models.default) && models.default.length > 0; + const paddedName = typeof entry.name === 'string' && entry.name.trim() !== entry.name; return [ + ...(paddedName && hasText(entry.name) ? [issue('name', 'name_whitespace')] : []), ...(hasText(entry.apiKey) || hasStoredKey ? [] : [issue('apiKey', 'required')]), ...(hasText(entry.baseURL) ? [] : [issue('baseURL', 'required')]), ...(hasModels ? [] : [issue('models.default', 'models_required')]),