diff --git a/e2e/specs.spec.ts b/e2e/specs.spec.ts new file mode 100644 index 00000000..85943b57 --- /dev/null +++ b/e2e/specs.spec.ts @@ -0,0 +1,207 @@ +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { LIVE, LIVE_SKIP_REASON } from './live'; + +/** + * Model specs journeys (WS11, the audit's specs journeys). They need a live + * LibreChat backend whose librechat.yaml defines at least one model spec and + * whose Base stores no spec list of its own (reset the admin overrides first): + * run with E2E_LIVE=1. Each journey that saves puts the list back to + * librechat.yaml before it ends. + */ +const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' }); +const cards = (page: Page) => page.locator('#section-modelSpecs\\.list [data-entry-card]'); + +async function openSpecs(page: Page, search = '') { + await page.goto(`/configuration?tab=specs${search}`); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /^Specs/ })).toBeVisible(); +} + +async function expand(card: Locator) { + const header = card.locator('button[aria-expanded]').first(); + if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click(); +} + +async function review(page: Page) { + await unsavedBar(page) + .getByRole('button', { name: /^Review & save$/ }) + .click(); + return page.getByRole('dialog'); +} + +async function saveAndWait(page: Page) { + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /^Save$/ }).click(); + await expect(dialog).toHaveCount(0); +} + +/** Puts the spec list back to librechat.yaml when the journey left an override. */ +async function revertList(page: Page) { + await openSpecs(page); + const revert = page.getByRole('button', { name: 'Revert list to librechat.yaml' }); + if ((await revert.count()) === 0) return; + await revert.click(); + await review(page); + await saveAndWait(page); +} + +/** Adds a spec with the four required fields, and returns its card. */ +async function addSpec(page: Page, label: string, endpoint: string, model: string) { + await page.getByRole('button', { name: 'Add spec' }).click(); + const card = cards(page).last(); + await expect(card.getByLabel(/^Label\*?$/)).toBeFocused(); + await card.getByLabel(/^Label\*?$/).fill(label); + await card.getByLabel(/^Label\*?$/).press('Tab'); + await card.getByLabel(/^Endpoint\*?$/).selectOption(endpoint); + await card.getByRole('combobox', { name: /^Model\*?$/ }).fill(model); + await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Tab'); + return card; +} + +test.describe('Model specs', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); + test.describe.configure({ mode: 'serial', timeout: 120_000 }); + + test('the tab says how users pick models and what they will see', async ({ page }) => { + await openSpecs(page); + await expect(page.getByRole('group', { name: 'How users pick models' })).toBeVisible(); + await expect(page.getByText('Users will see:')).toBeVisible(); + await expect(cards(page).first()).toBeVisible(); + await expect(page.getByText('List order is the order of the model menu.')).toBeVisible(); + }); + + test('a new spec asks for Endpoint and Model up front and saves at the end of the list', async ({ + page, + }) => { + await openSpecs(page); + const before = await cards(page).count(); + const card = await addSpec(page, 'E2E Spec', 'openAI', 'gpt-4o-mini'); + await expect(card.getByLabel(/^Name\*?$/)).toHaveValue('e2e-spec'); + const dialog = await review(page); + await expect(dialog).toContainText('Added model spec'); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(cards(page)).toHaveCount(before + 1); + await expect(cards(page).last()).toContainText('E2E Spec'); + await expect(cards(page).last()).toContainText('openAI · gpt-4o-mini'); + await revertList(page); + }); + + test('duplicate names and incomplete specs block Save', async ({ page }) => { + await openSpecs(page); + const first = cards(page).first(); + await expand(first); + const name = await first.getByLabel(/^Name\*?$/).inputValue(); + const card = await addSpec(page, 'Copy', 'openAI', 'gpt-4o'); + await card.getByLabel(/^Name\*?$/).fill(name); + await card.getByLabel(/^Name\*?$/).press('Tab'); + await expect(card.getByText('already uses this name')).toBeVisible(); + const dialog = await review(page); + await expect(dialog.getByText(/must be fixed before saving/)).toBeVisible(); + await expect(dialog.getByRole('button', { name: /^Save$/ })).toBeDisabled(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + }); + + test('only one spec can be the default', async ({ page }) => { + await openSpecs(page); + await addSpec(page, 'E2E A', 'openAI', 'gpt-4o'); + const b = await addSpec(page, 'E2E B', 'openAI', 'gpt-4o'); + const a = cards(page).nth((await cards(page).count()) - 2); + for (const card of [a, b]) { + await expand(card); + await card.getByRole('button', { name: /^New chats/ }).click(); + await card.getByRole('button', { name: 'Default', exact: true }).click(); + } + const pressed = (card: Locator) => + card.getByRole('button', { name: 'Default', exact: true }).getAttribute('aria-pressed'); + expect(await pressed(a)).toBe('false'); + expect(await pressed(b)).toBe('true'); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + }); + + test('reordering is saved (list order is menu order)', async ({ page }) => { + await openSpecs(page); + await addSpec(page, 'E2E Last', 'openAI', 'gpt-4o'); + await review(page); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: 'Move E2E Last up' }).click(); + const dialog = await review(page); + await expect(dialog).toContainText('Order changed'); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + const count = await cards(page).count(); + await expect(cards(page).nth(count - 2)).toContainText('E2E Last'); + await revertList(page); + }); + + test('Enter in a spec field never adds or removes an endpoint next to the specs', async ({ + page, + }) => { + await openSpecs(page); + const card = cards(page).first(); + await expand(card); + await card.getByLabel(/^Label\*?$/).press('Enter'); + await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Enter'); + await card.getByLabel('Model label', { exact: true }).press('Enter'); + await expect(unsavedBar(page)).toHaveCount(0); + const picker = page.locator('#modelSpecs-addedEndpoints-add'); + await expect(picker.locator('option[value=""]')).toHaveCount(0); + }); + + test('an Anthropic spec offers only Anthropic parameters', async ({ page }) => { + await openSpecs(page); + const card = await addSpec(page, 'E2E Claude', 'anthropic', 'claude-sonnet-4-5'); + await card.getByRole('button', { name: /^Parameters/ }).click(); + await expect(card.getByLabel('Top K', { exact: true })).toBeVisible(); + await expect(card.getByLabel('Thinking budget', { exact: true })).toBeVisible(); + await expect(card.getByLabel('Frequency penalty', { exact: true })).toHaveCount(0); + await expect(card.getByLabel('Reasoning effort', { exact: true })).toHaveCount(0); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + }); + + test('a deleted spec waits for Save with Undo', async ({ page }) => { + await openSpecs(page); + const before = await cards(page).count(); + const title = (await cards(page).first().locator('span.truncate').first().innerText()).trim(); + await page.getByRole('button', { name: `Delete ${title}` }).click(); + await expect(page.getByRole('button', { name: new RegExp(`Undo.*${title}`) })).toBeFocused(); + await page.getByRole('button', { name: new RegExp(`Undo.*${title}`) }).click(); + await expect(cards(page)).toHaveCount(before); + await expect(unsavedBar(page)).toHaveCount(0); + }); + + test('a profile shows the specs it inherits read-only until it customizes the list', async ({ + page, + }) => { + await openSpecs(page, '&scope=role:USER'); + const customize = page.getByRole('button', { name: /^Customize list for/ }); + test.skip((await customize.count()) === 0, 'Needs a USER role profile without its own list'); + await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0); + await expect(cards(page).first()).toBeVisible(); + await customize.click(); + await expect(page.getByRole('button', { name: 'Add spec' })).toBeVisible(); + await expect(page.getByText(/replaces Base's list/)).toBeVisible(); + }); + + test('the tab filter finds spec fields and phone width has no sideways scroll', async ({ + page, + }) => { + await openSpecs(page); + await page.getByPlaceholder('Filter this tab…').fill('soft default'); + await expect(page.getByText('Default for new chats').first()).toBeVisible(); + await page.getByPlaceholder('Filter this tab…').fill(''); + await page.setViewportSize({ width: 390, height: 900 }); + await expand(cards(page).first()); + const overflow = await page.evaluate(() => { + const el = document.querySelector('.scrollbar-gutter-stable'); + return el ? el.scrollWidth - el.clientWidth : 0; + }); + expect(overflow).toBeLessThanOrEqual(0); + }); +}); diff --git a/src/components/configuration/edits.test.ts b/src/components/configuration/edits.test.ts index 6c8a4389..18f80541 100644 --- a/src/components/configuration/edits.test.ts +++ b/src/components/configuration/edits.test.ts @@ -112,8 +112,8 @@ describe('identity-keyed collection ops (SV-1)', () => { context({ collections }), ); expect(ops).toEqual([ - expect.objectContaining({ op: 'upsertEntry', key: 'AdminC', identity: 'AdminB' }), { op: 'removeEntry', path: 'endpoints.custom', key: 'AdminA', identity: 'AdminA' }, + expect.objectContaining({ op: 'upsertEntry', key: 'AdminC', identity: 'AdminB' }), ]); }); @@ -205,6 +205,95 @@ describe('identity-keyed collection ops (SV-1)', () => { ]); }); + it('sends a reordered spec list with every kept spec at its new index', () => { + const specList: t.ConfigRecord[] = [ + { name: 'a', label: 'A', preset: { endpoint: 'openAI', model: 'm' } }, + { name: 'b', label: 'B', preset: { endpoint: 'openAI', model: 'm' } }, + { name: 'c', label: 'C', preset: { endpoint: 'openAI', model: 'm' } }, + ]; + const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } }; + const reordered = [specList[2], specList[0], specList[1]]; + const collections = trackCollectionEdit({}, {}, 'modelSpecs.list', reordered, specRoot); + const { ops } = buildConfigChanges( + new Set(['modelSpecs.list']), + { 'modelSpecs.list': reordered }, + schemaPaths, + context({ root: specRoot, collections }), + ); + expect(ops).toEqual([ + expect.objectContaining({ op: 'upsertEntry', identity: 'c', index: 0 }), + expect.objectContaining({ op: 'upsertEntry', identity: 'a', index: 1 }), + expect.objectContaining({ op: 'upsertEntry', identity: 'b', index: 2 }), + ]); + const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' }); + expect(plan.sets.map((set) => set.fieldPath)).toEqual(['modelSpecs.list']); + const saved = plan.sets[0].value as t.ConfigRecord[]; + expect(saved.map((spec) => spec.name)).toEqual(['c', 'a', 'b']); + }); + + it('keeps the spec order when a save both removes and adds specs', () => { + const spec = (name: string): t.ConfigRecord => ({ + name, + label: name.toUpperCase(), + preset: { endpoint: 'openAI', model: 'm' }, + }); + const specList = [spec('a'), spec('b'), spec('c')]; + const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } }; + const pending = [specList[1], specList[2], spec('x')]; + const collections = trackCollectionEdit({}, {}, 'modelSpecs.list', pending, specRoot); + const { ops } = buildConfigChanges( + new Set(['modelSpecs.list']), + { 'modelSpecs.list': pending }, + schemaPaths, + context({ root: specRoot, collections }), + ); + const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' }); + expect(plan.issues).toEqual([]); + const saved = plan.sets[0].value as t.ConfigRecord[]; + expect(saved.map((entry) => entry.name)).toEqual(['b', 'c', 'x']); + }); + + it('saves two specs whose names were swapped', () => { + const specList: t.ConfigRecord[] = [ + { name: 'a', label: 'A', preset: { endpoint: 'openAI', model: 'm' } }, + { name: 'b', label: 'B', preset: { endpoint: 'openAI', model: 'm' } }, + ]; + const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } }; + const edited: t.FlatConfigMap = { + 'modelSpecs.list.0': { ...specList[0], name: 'b' }, + 'modelSpecs.list.1': { ...specList[1], name: 'a' }, + }; + const collections = Object.entries(edited).reduce( + (acc, [path, value]) => trackCollectionEdit(acc, {}, path, value, specRoot), + {}, + ); + const { ops } = buildConfigChanges( + new Set(Object.keys(edited)), + edited, + schemaPaths, + context({ root: specRoot, collections }), + ); + const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' }); + expect(plan.issues).toEqual([]); + const saved = plan.sets[0].value as t.ConfigRecord[]; + expect(saved.map((spec) => [spec.name, spec.label])).toEqual([ + ['b', 'A'], + ['a', 'B'], + ]); + }); + + it('sends no positions for a reordered custom endpoint list (merged by name)', () => { + const reordered = [endpoints[2], endpoints[0], endpoints[1]]; + const collections = trackCollectionEdit({}, {}, 'endpoints.custom', reordered, root); + const { ops } = buildConfigChanges( + new Set(['endpoints.custom']), + { 'endpoints.custom': reordered }, + schemaPaths, + context({ collections }), + ); + expect(ops).toEqual([]); + }); + it('turns a reset of the whole collection into a reset op (revert list to librechat.yaml)', () => { const values = { 'modelSpecs.list': undefined }; const { ops } = buildConfigChanges( diff --git a/src/components/configuration/edits.ts b/src/components/configuration/edits.ts index 54f8d903..81d57d4f 100644 --- a/src/components/configuration/edits.ts +++ b/src/components/configuration/edits.ts @@ -304,10 +304,36 @@ export function trackCollectionEdit( } /** - * The entry ops for one collection: an upsert for each added or changed item - * (keyed by the identity it was loaded with, with that loaded entry as the - * baseline so only the edit is applied) and a remove for each loaded entry no - * pending item came from. Items without a key are reported, not sent. + * Whether the pending items keep loaded entries in a different order than + * they were loaded in. Only lists whose order matters (model specs: list + * order is menu order) send it. + */ +function isReordered( + collection: t.KeyedCollection, + items: t.ConfigValue[], + edit: t.CollectionEdit, +): boolean { + if (collection.merge !== 'replace') return false; + const pending = items.flatMap((_, index) => { + const origin = edit.origins[index]; + return origin ? [origin.identity] : []; + }); + const kept = new Set(pending); + const loaded = edit.baseline.flatMap((entry) => { + const identity = getEntryKey(entry, collection.key); + return identity !== undefined && kept.has(identity) ? [identity] : []; + }); + return pending.some((identity, i) => identity !== loaded[i]); +} + +/** + * The entry ops for one collection: a remove for each loaded entry no pending + * item came from, then an upsert for each added or changed item (keyed by the + * identity it was loaded with, with that loaded entry as the baseline so only + * the edit is applied). Items without a key are reported, not sent. Removes + * go first so each `index` (a new entry's position, or every kept entry's + * when a list whose order matters was reordered) counts only the entries + * that stay: applied in order, the upserts rebuild the pending order. */ export function diffCollection( collection: t.KeyedCollection, @@ -315,12 +341,22 @@ export function diffCollection( edit: t.CollectionEdit, normalize: (value: t.ConfigValue) => t.ConfigValue, ): { ops: t.ConfigChange[]; issues: t.ConfigIssue[] } { - const ops: t.ConfigChange[] = []; const issues: t.ConfigIssue[] = []; - const kept = new Set(); + const kept = new Set( + items.flatMap((_, index) => { + const origin = edit.origins[index]; + return origin ? [origin.identity] : []; + }), + ); + const ops: t.ConfigChange[] = edit.baseline.flatMap((entry): t.ConfigChange[] => { + const identity = getEntryKey(entry, collection.key); + return identity === undefined || kept.has(identity) + ? [] + : [{ op: 'removeEntry', path: collection.path, key: identity, identity }]; + }); + const moved = isReordered(collection, items, edit); items.forEach((item, index) => { const origin = edit.origins[index] ?? null; - if (origin) kept.add(origin.identity); if (item === undefined) return; const value = normalize(item); const key = getEntryKey(value, collection.key); @@ -339,7 +375,7 @@ export function diffCollection( return; } const baseline = normalize(origin.baseline); - if (isConfigValueEqual(value, baseline)) return; + if (!moved && isConfigValueEqual(value, baseline)) return; ops.push({ op: 'upsertEntry', path: collection.path, @@ -347,14 +383,9 @@ export function diffCollection( identity: origin.identity, value, baseline, + ...(moved && { index }), }); }); - for (const entry of edit.baseline) { - const identity = getEntryKey(entry, collection.key); - if (identity !== undefined && !kept.has(identity)) { - ops.push({ op: 'removeEntry', path: collection.path, key: identity, identity }); - } - } return { ops, issues }; } diff --git a/src/components/configuration/fields/ArrayObjectField.tsx b/src/components/configuration/fields/ArrayObjectField.tsx index b0322e65..9bf0926b 100644 --- a/src/components/configuration/fields/ArrayObjectField.tsx +++ b/src/components/configuration/fields/ArrayObjectField.tsx @@ -29,6 +29,8 @@ export function ArrayObjectField({ editSessionId, getEntryTitle: getTitle, renderSummary, + renderLeading, + stackedSummary, reorderable, emptyLabel, isEntryLocked, @@ -192,6 +194,8 @@ export function ArrayObjectField({ reorderable && index < items.length - 1 ? () => handleMove(index, 1) : undefined } summary={renderSummary?.(item, index)} + leading={renderLeading?.(item, index)} + stackedSummary={stackedSummary} disabled={disabled} isNew={newKeys.has(key)} markers={newKeys.has(key) ? undefined : markersOf?.(item)} diff --git a/src/components/configuration/fields/ObjectEntryCard.tsx b/src/components/configuration/fields/ObjectEntryCard.tsx index a4ed77c2..dd836e28 100644 --- a/src/components/configuration/fields/ObjectEntryCard.tsx +++ b/src/components/configuration/fields/ObjectEntryCard.tsx @@ -45,6 +45,8 @@ export function ObjectEntryCard({ isNew, markers, summary, + leading, + stackedSummary, disabled, defaultExpanded = false, renderFields, @@ -173,6 +175,12 @@ export function ObjectEntryCard({ }, }); + const newChip = isNew ? ( + + {localize('com_config_ctl_entry_new')} + + ) : null; + if (pendingRemoval) { return (
- - {entryKey} - - {isNew && ( - - {localize('com_config_ctl_entry_new')} - - )} - {markers && } - {summaryNode && !isExpanded && ( - - {summaryNode} + {leading} + {stackedSummary ? ( + + + + {entryKey} + + {newChip} + {markers && } + + {summaryNode && !isExpanded && ( + + {summaryNode} + + )} + ) : ( + <> + + {entryKey} + + {newChip} + {markers && } + {summaryNode && !isExpanded && ( + + {summaryNode} + + )} + )} )} diff --git a/src/components/configuration/meta/index.ts b/src/components/configuration/meta/index.ts index 21606291..f2cae690 100644 --- a/src/components/configuration/meta/index.ts +++ b/src/components/configuration/meta/index.ts @@ -4,12 +4,14 @@ import { PROVIDER_FIELD_META } from './providers'; import { SECRET_CONFIG_KEYS } from '@/constants'; import { ENDPOINT_FIELD_META } from './endpoints'; import { GENERIC_FIELD_META } from './generic'; +import { SPEC_FIELD_META } from './specs'; /** Registries merged in order; area files are appended here (WS2 owns, WS9–13 contribute). */ const META_SOURCES: t.FieldMetaRegistry[] = [ GENERIC_FIELD_META, PROVIDER_FIELD_META, ENDPOINT_FIELD_META, + SPEC_FIELD_META, ]; const REGISTRY = buildFieldMetaRegistry(META_SOURCES); diff --git a/src/components/configuration/meta/specs.ts b/src/components/configuration/meta/specs.ts new file mode 100644 index 00000000..0ef218cb --- /dev/null +++ b/src/components/configuration/meta/specs.ts @@ -0,0 +1,34 @@ +import type * as t from '@/types'; + +/** + * Model spec field metadata (WS11): labels and help where the schema's own + * read wrong in the spec editor, the review and search. + */ +export const SPEC_FIELD_META: t.FieldMetaRegistry = { + 'modelSpecs.list': { labelKey: 'com_config_specs_list' }, + /** LibreChat lists specs in list order; `order` sorts nothing in its model menu. */ + 'modelSpecs.list[].order': { descriptionKey: 'com_config_specs_order_desc' }, + /** The provider's own search tool, apart from the spec's Web search capability. */ + 'modelSpecs.list[].preset.web_search': { labelKey: 'com_config_specs_provider_web_search' }, + 'modelSpecs.list[].preset.greeting': { multiline: true }, + /** Unset, the spec's agent gets no ask-the-user tool. */ + 'modelSpecs.list[].askUserQuestion': { runtimeDefault: false }, + /** Unset, LibreChat lets the provider pick (the parameter panel's "Auto"). */ + ...Object.fromEntries( + [ + 'effort', + 'reasoning_effort', + 'reasoning_summary', + 'reasoning_mode', + 'reasoning_context', + 'verbosity', + 'thinkingLevel', + ].map((key) => [ + `modelSpecs.list[].preset.${key}`, + { defaultLabelKey: 'com_config_specs_default_auto' }, + ]), + ), + 'modelSpecs.list[].preset.promptCacheTtl': { + defaultLabelKey: 'com_config_specs_default_provider', + }, +}; diff --git a/src/components/configuration/search.ts b/src/components/configuration/search.ts index 0ad9b924..fcd37180 100644 --- a/src/components/configuration/search.ts +++ b/src/components/configuration/search.ts @@ -19,6 +19,7 @@ import { getFieldMeta, labelForPath, describeField } from './meta'; import { formatFieldValue } from './controls'; import { inferTransportType } from './sections/mcp/validation'; import { isMcpFieldShown } from './sections/mcpFieldMeta'; +import { specSearchFields } from './sections/specs/params'; import { getEntryTitle } from './fields/entries'; const VALUE_MAX = 40; @@ -219,7 +220,15 @@ function walkField(ctx: WalkContext, field: t.SchemaField, path: string, breadcr pushItem(ctx, { label: title, breadcrumb: entryCrumbs, path: entryPath, kind: 'entry' }, [ segment, ]); - walkChildren(ctx, field.children ?? [], entryPath, crumbs([...entryCrumbs, title])); + const children = + path === 'modelSpecs.list' + ? specSearchFields( + field.children ?? [], + getValueAtPath(values ?? null, entryPath), + values ?? null, + ) + : (field.children ?? []); + walkChildren(ctx, children, entryPath, crumbs([...entryCrumbs, title])); } return; } diff --git a/src/components/configuration/sections/index.ts b/src/components/configuration/sections/index.ts index bb2717be..7dcca87a 100644 --- a/src/components/configuration/sections/index.ts +++ b/src/components/configuration/sections/index.ts @@ -15,6 +15,7 @@ import { McpServersRenderer } from './McpServersRenderer'; import { CustomEndpointsRenderer } from './endpoints'; import { LangfuseRenderer } from './LangfuseRenderer'; import { ProvidersRenderer } from './providers'; +import { ModelSpecsRenderer } from './specs'; export const SECTION_RENDERERS: Partial>> = { @@ -22,6 +23,7 @@ export const SECTION_RENDERERS: Partial(null); + const labels = new Map( + groups.flatMap((group) => group.options.map((option) => [option.value, option.label])), + ); + const picked = new Set(values); + const remaining = groups + .map((group) => ({ + ...group, + options: group.options.filter((option) => !picked.has(option.value)), + })) + .filter((group) => group.options.length > 0); + + const add = (value: string) => { + setNotice(null); + setAnnouncement(localize('com_config_specs_chip_added', { name: labels.get(value) ?? value })); + onChange([...values, value]); + }; + const remove = (value: string) => { + const next = values.filter((item) => item !== value); + setAnnouncement( + localize('com_config_specs_chip_removed', { name: labels.get(value) ?? value }), + ); + if (next.length > 0 || !onClear) { + setNotice(null); + onChange(next); + return; + } + setNotice(onClear() === false ? localize('com_config_ctl_clear_unavailable') : null); + }; + + return ( +
+ {values.length > 0 && ( +
    + {values.map((value) => { + const label = labels.get(value); + const known = label !== undefined; + return ( +
  • + {!known && } + {known ? label : unknownLabel(value)} + {!disabled && ( + + )} +
  • + ); + })} +
+ )} + {!disabled && remaining.length > 0 && ( +
+ + +
+ )} + {disabled && values.length === 0 && ( + + {localize('com_config_specs_chip_none')} + + )} + {notice && {notice}} + + {announcement} + +
+ ); +} diff --git a/src/components/configuration/sections/specs/GroupedSelect.tsx b/src/components/configuration/sections/specs/GroupedSelect.tsx new file mode 100644 index 00000000..5d2c89f7 --- /dev/null +++ b/src/components/configuration/sections/specs/GroupedSelect.tsx @@ -0,0 +1,66 @@ +import { Icon } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { INPUT_CLASSES, widthClass } from '../../fields/display'; +import { cn } from '@/utils'; + +const PLACEHOLDER = '\u0000placeholder'; + +/** + * A native select with option groups (Providers, Custom endpoints), so the + * label targets the control and the OS list keeps type-ahead. A value that + * matches no option is kept, selected and labelled as unknown. + */ +export function GroupedSelect({ + id, + value, + groups, + onChange, + placeholder, + unknownLabel, + required, + disabled, + 'aria-describedby': ariaDescribedBy, +}: t.GroupedSelectProps) { + const isUnset = value == null || value === ''; + const isKnown = + isUnset || groups.some((group) => group.options.some((option) => option.value === value)); + return ( +
+ + +
+ ); +} diff --git a/src/components/configuration/sections/specs/ModelSpecsRenderer.tsx b/src/components/configuration/sections/specs/ModelSpecsRenderer.tsx new file mode 100644 index 00000000..1748e5d3 --- /dev/null +++ b/src/components/configuration/sections/specs/ModelSpecsRenderer.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import type * as t from '@/types'; +import { SPEC_LIST_PATH, validateSpecs } from './validation'; +import { UnknownSettings } from '../../UnknownSettings'; +import { SpecsSettings } from './SpecsSettings'; +import { useSpecOptions } from './options'; +import { SpecList } from './SpecList'; +import { isConfigRecord } from '@/utils'; + +const NO_ITEMS: t.ConfigValue[] = []; + +/** + * The Model specs tab (WS11): how users pick models (specs only, specs + * first, endpoints first) with the endpoints listed beside the specs, then + * the spec list and its editor. Specs are validated as the admin edits them; + * errors show on the spec and its fields and block Save (MS-2). + */ +export function ModelSpecsRenderer(props: t.ModelSpecsRendererProps) { + const { fields, parentValue, parentPath, getValue } = props; + const listField = fields.find((field) => field.key === 'list'); + const stored = isConfigRecord(parentValue) ? parentValue.list : undefined; + const value = getValue(SPEC_LIST_PATH, stored ?? NO_ITEMS); + const items = Array.isArray(value) ? value : NO_ITEMS; + const options = useSpecOptions(items); + const problems = useMemo( + () => validateSpecs(items, { knownEndpoints: options.knownEndpoints }), + [items, options.knownEndpoints], + ); + + return ( + <> + + {listField && ( + + )} + + + ); +} diff --git a/src/components/configuration/sections/specs/PresetEditor.tsx b/src/components/configuration/sections/specs/PresetEditor.tsx new file mode 100644 index 00000000..811f14f2 --- /dev/null +++ b/src/components/configuration/sections/specs/PresetEditor.tsx @@ -0,0 +1,262 @@ +import { useMemo, useState, useEffect, useRef, useContext } from 'react'; +import { Icon } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { MODEL_KEYS, PROMPT_KEYS, PRESET_DENYLIST, paramField } from './params'; +import { INPUT_CLASSES, widthClass } from '../../fields/display'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { renderInlineField, toFieldId } from '../../renderer'; +import { FieldControl } from '../../renderer/FieldControl'; +import { cn, isConfigRecord, isRowVisible } from '@/utils'; +import { describeField, labelForPath } from '../../meta'; +import { useFieldError, useLocalize } from '@/hooks'; +import { CodeField } from '../../fields/CodeField'; +import { SettingsRow } from '../../SettingsRow'; +import { specEndpoint } from './validation'; +import { useSpecList } from './context'; + +const PICK = '\u0000pick'; + +/** One model parameter: the shared control with the endpoint's range, choices and default. */ +function ParamRow({ + field, + param, + value, + path, + onChange, + disabled, + editSessionId, +}: { + field: t.SchemaField; + param: t.PresetParam; + value: t.ConfigValue; + path: string; + onChange: (key: string, value: t.ConfigValue) => void; + disabled?: boolean; + editSessionId?: number; +}) { + const localize = useLocalize(); + const dataPath = `${path}.${param.key}`; + const id = toFieldId(dataPath); + const label = labelForPath(dataPath, localize); + const error = useFieldError(dataPath); + const shaped = paramField(field, param); + return ( + onChange(param.key, next)} + onClear={() => { + onChange(param.key, undefined); + return true; + }} + fieldDefault={{ + hasOwnValue: value !== undefined, + value: param.defaultValue, + source: 'runtimeDefault', + }} + disabled={disabled} + editSessionId={editSessionId} + /> + } + /> + ); +} + +/** + * A spec's model parameters (MS-4), driven by LibreChat's own parameter + * settings for its endpoint and model: only the parameters that endpoint + * offers, with their ranges, choices and defaults. Preset fields the spec + * sets that the endpoint doesn't offer stay listed (so they can be changed or + * removed); "Advanced (raw)" adds any other preset field LibreChat accepts, + * internal conversation fields excepted, or edits the preset as YAML. While + * the tab filter is on, only the matching parameters are listed. + */ +export function PresetEditor({ + presetField, + preset, + params, + path, + onChange, + onReplace, + disabled, + editSessionId, +}: t.PresetEditorProps) { + const localize = useLocalize(); + const { options, configuredOnly } = useSpecList(); + const rowFilter = useContext(RowFilterContext); + const [addedKeys, setAddedKeys] = useState([]); + const [rawOpen, setRawOpen] = useState(false); + const focusKeyRef = useRef(null); + const endpoint = specEndpoint({ preset }); + const children = presetField.children ?? []; + const byKey = useMemo(() => new Map(children.map((field) => [field.key, field])), [children]); + const isShown = (key: string) => isRowVisible(rowFilter, `${path}.${key}`); + const showsNotes = !configuredOnly && !rowFilter; + const endpointLabel = + options.endpoints.find((option) => option.value === endpoint)?.label ?? endpoint; + + const handled = new Set([...MODEL_KEYS, ...PROMPT_KEYS, ...params.map((param) => param.key)]); + const others = children.filter( + (field) => + !handled.has(field.key) && + (preset[field.key] !== undefined || addedKeys.includes(field.key)) && + isShown(field.key), + ); + const pickable = children + .filter( + (field) => + !handled.has(field.key) && + !PRESET_DENYLIST.has(field.key) && + preset[field.key] === undefined && + !addedKeys.includes(field.key), + ) + .map((field) => ({ + value: field.key, + label: localize('com_config_specs_raw_option', { + label: labelForPath(`${path}.${field.key}`, localize), + key: field.key, + }), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + useEffect(() => { + const key = focusKeyRef.current; + if (!key) return; + focusKeyRef.current = null; + const el = document.getElementById(toFieldId(`${path}.${key}`)); + const target = el?.matches('input, select, textarea, button') + ? el + : el?.querySelector('input, select, textarea, button'); + target?.focus(); + }); + + return ( + <> + {!endpoint && showsNotes && ( +

{localize('com_config_specs_params_no_endpoint')}

+ )} + {endpoint && params.length === 0 && showsNotes && ( +

+ {localize('com_config_specs_params_none', { endpoint: endpointLabel })} +

+ )} + {params.map((param) => { + const field = byKey.get(param.key); + if (!field || (configuredOnly && preset[param.key] === undefined) || !isShown(param.key)) { + return null; + } + return ( + + ); + })} + {others.length > 0 && ( + <> +

+ + {localize('com_config_specs_params_other')} + {' '} + {localize('com_config_specs_params_other_desc', { + endpoint: endpointLabel || localize('com_config_specs_this_endpoint'), + })} +

+ {others.map((field) => + renderInlineField( + field, + preset, + path, + onChange, + localize, + disabled, + undefined, + false, + editSessionId, + ), + )} + + )} + {!disabled && showsNotes && ( +
+ + {rawOpen && ( +
+

+ {localize('com_config_specs_raw_desc')} +

+ {pickable.length > 0 && ( +
+ + +
+ )} +
+ + onReplace(isConfigRecord(next) ? next : {})} + /> +
+
+ )} +
+ )} + + ); +} diff --git a/src/components/configuration/sections/specs/SpecEditor.tsx b/src/components/configuration/sections/specs/SpecEditor.tsx new file mode 100644 index 00000000..9d89a1d5 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecEditor.tsx @@ -0,0 +1,368 @@ +import { useMemo, useRef, useCallback, useContext } from 'react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + NameField, + McpField, + IconField, + ModelField, + AgentField, + LabelField, + GroupField, + DefaultField, + EndpointField, +} from './SpecFields'; +import { MODEL_KEYS, presetParams, paramGroupKeys, specModelKeys, specPromptKeys } from './params'; +import { isAgentSpec, problemsOf, specEndpoint } from './validation'; +import { getUnknownConfigEntries, isConfigRecord } from '@/utils'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { UnknownSettings } from '../../UnknownSettings'; +import { renderInlineField } from '../../renderer'; +import { useSpecList, specIndexOf } from './context'; +import { PresetEditor } from './PresetEditor'; +import { useLocalize } from '@/hooks'; +import { SpecGroup } from './SpecGroup'; + +const BASICS = ['label', 'name', 'description', 'iconURL']; +const CAPABILITIES = [ + 'webSearch', + 'fileSearch', + 'executeCode', + 'memory', + 'askUserQuestion', + 'artifacts', + 'runInBackground', + 'describeIntent', + 'skills', + 'mcpServers', +]; +const MENU = [ + 'showInMenu', + 'group', + 'groupIcon', + 'showIconInMenu', + 'showIconInHeader', + 'showOnLanding', + 'hideBadgeRow', + 'conversation_starters', +]; +const DEFAULTS = ['default', 'softDefault']; +const SUBAGENTS = 'subagents'; +const ADVANCED = ['authType', 'order']; +const GROUPED: ReadonlySet = new Set([ + ...BASICS, + ...CAPABILITIES, + ...MENU, + ...DEFAULTS, + ...ADVANCED, + SUBAGENTS, + 'preset', +]); + +function hasValue(value: t.ConfigValue): boolean { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + return !isConfigRecord(value) || Object.keys(value).length > 0; +} + +function countSet(record: t.ConfigRecord, keys: readonly string[]): number { + return keys.filter((key) => hasValue(record[key])).length; +} + +/** + * Writes one key of an object field (`preset`, `subagents`), merged into its + * latest value so two changes in one tick both land. An emptied optional + * object is removed; the required preset stays an object. + */ +function useChildChange( + parent: t.ConfigRecord, + key: string, + onChange: (key: string, value: t.ConfigValue) => void, + keepEmpty: boolean, +) { + const latest = useRef(parent); + latest.current = parent; + const change = useCallback( + (childKey: string, childValue: t.ConfigValue) => { + const next = { ...latest.current, [childKey]: childValue }; + if (childValue === undefined) delete next[childKey]; + latest.current = next; + onChange(key, keepEmpty || Object.keys(next).length > 0 ? next : undefined); + }, + [key, onChange, keepEmpty], + ); + const replace = useCallback( + (next: t.ConfigRecord) => { + latest.current = next; + onChange(key, next); + }, + [key, onChange], + ); + return { change, replace }; +} + +/** + * The editor of one model spec (MS-1), in groups: Basics, Model, Parameters, + * Prompt, Capabilities, Menu, New chats, Subagents and Advanced. Basics and + * Model (with the required name, label, endpoint and model) open first; + * other groups open for a problem or a search hit inside them. Fields the + * schema adds later land in Advanced; keys the panel doesn't know are listed + * read-only (D8). + */ +export function SpecEditor({ + fields, + value, + path, + onChange, + disabled, + editSessionId, +}: t.SpecEditorProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const { problems, options, configuredOnly } = useSpecList(); + const index = specIndexOf(path); + const spec = useMemo(() => (isConfigRecord(value) ? value : {}), [value]); + const byKey = useMemo(() => new Map(fields.map((field) => [field.key, field])), [fields]); + const presetField = byKey.get('preset'); + const presetKeys = useMemo( + () => new Set((presetField?.children ?? []).map((child) => child.key)), + [presetField], + ); + const subagentsField = byKey.get(SUBAGENTS); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const subagents = isConfigRecord(spec.subagents) ? spec.subagents : {}; + const presetChange = useChildChange(preset, 'preset', onChange, true); + const subagentsChange = useChildChange(subagents, SUBAGENTS, onChange, false); + const presetPath = `${path}.preset`; + const endpoint = specEndpoint(spec); + const isAgent = isAgentSpec(spec); + const model = typeof preset.model === 'string' ? preset.model.trim() : ''; + const promptKeys = specPromptKeys(preset, endpoint, options.customEndpoint(endpoint)); + const { params } = useMemo( + () => presetParams(endpoint, model, options.customEndpoint(endpoint), presetKeys), + [endpoint, model, options, presetKeys], + ); + const paramKeys = paramGroupKeys(params, preset); + const errorFields = new Set( + problemsOf(problems, index) + .filter((problem) => problem.level === 'error') + .map((problem) => problem.field), + ); + const opensFor = (keys: string[]) => keys.some((key) => errorFields.has(key)); + const paths = (keys: string[], base = path) => keys.map((key) => `${base}.${key}`); + const rest = fields.filter((field) => !GROUPED.has(field.key)); + + /** + * Under "Set values only", a row (or group) without a value is left out, + * unless it needs one: a required field still empty (a new spec's Label, + * Name, Endpoint and Model) stays, so the spec can be completed. + */ + const shows = (record: t.ConfigRecord, key: string, problemField = key) => + !configuredOnly || hasValue(record[key]) || errorFields.has(problemField); + const showsPreset = (key: string) => shows(preset, key, `preset.${key}`); + const showsGroup = (count: number) => !configuredOnly || count > 0; + const modelKeys = specModelKeys(preset, endpoint); + const paramCount = Object.keys(preset).filter( + (key) => !MODEL_KEYS.includes(key) && !promptKeys.includes(key), + ).length; + const subagentKeys = (subagentsField?.children ?? []).map((field) => field.key); + const advancedKeys = [...ADVANCED, ...rest.map((field) => field.key)]; + const unknownCount = + getUnknownConfigEntries(fields, spec).length + + getUnknownConfigEntries(presetField?.children ?? [], preset).length; + + const row = (key: string) => { + const field = byKey.get(key); + if (!field || !shows(spec, key)) return null; + return renderInlineField( + field, + spec, + path, + onChange, + localize, + disabled, + undefined, + true, + editSessionId, + ); + }; + const presetRow = (key: string) => { + const field = presetField?.children?.find((child) => child.key === key); + if (!field || !showsPreset(key)) return null; + return renderInlineField( + field, + preset, + presetPath, + presetChange.change, + localize, + disabled, + undefined, + false, + editSessionId, + ); + }; + const presetChildField = (key: string) => + presetField?.children?.find((child) => child.key === key); + const fieldProps = { spec, index, path, onChange, disabled }; + const presetProps = { ...fieldProps, onPresetChange: presetChange.change }; + /** The endpoint row also stands for one LibreChat infers (agents, from an agent ID). */ + const modelRow = (key: string): ReactNode => { + const field = presetChildField(key); + if (key === 'endpoint') { + return ( + shows({ endpoint }, key, 'preset.endpoint') && ( + + ) + ); + } + if (key === 'agent_id' && isAgent) { + return showsPreset(key) && ; + } + if (key === 'model' && !isAgent) { + return showsPreset(key) && ; + } + return presetRow(key); + }; + + return ( +
+ {(showsGroup(countSet(spec, BASICS)) || opensFor(BASICS)) && ( + + {shows(spec, 'label') && } + {shows(spec, 'name') && } + {row('description')} + {shows(spec, 'iconURL') && } + + )} + + + {modelKeys.map(modelRow)} + + + {presetField && showsGroup(paramCount) && (paramKeys.length > 0 || !rowFilter) && ( + 0 ? paths(paramKeys, presetPath) : [presetPath]} + defaultExpanded={false} + > + + + )} + + {showsGroup(countSet(preset, promptKeys)) && ( + + {promptKeys.map((key) => presetRow(key))} + + )} + + {showsGroup(countSet(spec, CAPABILITIES)) && ( + + {CAPABILITIES.filter((key) => key !== 'mcpServers').map((key) => row(key))} + {shows(spec, 'mcpServers') && ( + + )} + + )} + + {showsGroup(countSet(spec, MENU)) && ( + + {row('showInMenu')} + {shows(spec, 'group') && } + {shows(spec, 'groupIcon') && ( + + )} + {MENU.slice(3).map((key) => row(key))} + + )} + + {showsGroup(countSet(spec, DEFAULTS)) && ( + + + + )} + + {subagentsField && showsGroup(countSet(subagents, subagentKeys)) && ( + + {(subagentsField.children ?? []).map((field) => + !shows(subagents, field.key) + ? null + : renderInlineField( + field, + subagents, + `${path}.${SUBAGENTS}`, + subagentsChange.change, + localize, + disabled, + undefined, + false, + editSessionId, + ), + )} + + )} + + {showsGroup(countSet(spec, advancedKeys) + unknownCount) && ( + + {ADVANCED.map((key) => row(key))} + {rest.map((field) => row(field.key))} + + {presetField && ( + + )} + + )} +
+ ); +} diff --git a/src/components/configuration/sections/specs/SpecFieldRow.tsx b/src/components/configuration/sections/specs/SpecFieldRow.tsx new file mode 100644 index 00000000..d6f7597f --- /dev/null +++ b/src/components/configuration/sections/specs/SpecFieldRow.tsx @@ -0,0 +1,75 @@ +import { useState, useContext } from 'react'; +import type * as t from '@/types'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { useFieldError, useLocalize } from '@/hooks'; +import { toFieldId } from '../../renderer'; +import { SettingsRow } from '../../SettingsRow'; +import { isRowVisible } from '@/utils'; + +/** Codes whose message is a predicate of the field ("is required"), read after its label. */ +const PREDICATE_CODES: ReadonlySet = new Set(['required']); + +/** A spec problem as text: "Label is required", "Spec 1 (“GPT”) already uses this name". */ +export function problemText(problem: t.SpecProblem, label: string, localize: t.LocalizeFn): string { + const text = localize(`com_config_issue_${problem.code}`, problem.params); + return PREDICATE_CODES.has(problem.code) ? `${label} ${text}` : text; +} + +/** + * A spec editor row with its own control (Name, Endpoint, Model, Default…): + * the row grid, its required marker, and its problem: an error under the + * control (else the save pipeline's error for the field), or a warning that + * doesn't block Save. + */ +export function SpecFieldRow({ + path, + label, + description, + required, + problem, + control, + width, + quietWhenEmpty, + matchPaths, +}: t.SpecFieldRowProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const pipelineError = useFieldError(path); + const [visited, setVisited] = useState(false); + if (![path, ...(matchPaths ?? [])].some((candidate) => isRowVisible(rowFilter, candidate))) { + return null; + } + const id = toFieldId(path); + const quiet = Boolean(quietWhenEmpty) && !visited; + const text = problem ? problemText(problem, label, localize) : undefined; + const reported = problem?.level === 'error' ? text : pipelineError; + const error = quiet ? undefined : reported; + const warning = + problem?.level === 'warning' ? ( +

+ {text} +

+ ) : null; + return ( + { + if (!event.currentTarget.contains(event.relatedTarget)) setVisited(true); + }} + > + {control} + {warning} +
+ } + /> + ); +} diff --git a/src/components/configuration/sections/specs/SpecFields.tsx b/src/components/configuration/sections/specs/SpecFields.tsx new file mode 100644 index 00000000..4b35ed85 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecFields.tsx @@ -0,0 +1,483 @@ +import { useContext, useId } from 'react'; +import { ButtonGroup } from '@clickhouse/click-ui'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { problemAt, specEndpoint, SPEC_LIST_PATH } from './validation'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { useLocalize, useOptionSources } from '@/hooks'; +import { endpointGroups, mcpGroups } from './groups'; +import { TextField } from '../../fields/TextField'; +import { describeField, labelForPath } from '../../meta'; +import { GroupedSelect } from './GroupedSelect'; +import { ASSISTANT_ENDPOINTS } from './params'; +import { SpecFieldRow } from './SpecFieldRow'; +import { toFieldId } from '../../renderer'; +import { uniqueOptions } from './options'; +import { ChipPicker } from './ChipPicker'; +import { useSpecList } from './context'; +import { SpecIcon } from './SpecIcon'; +import { isConfigRecord } from '@/utils'; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value : ''; +} + +/** "OpenAI GPT-5" → "openai-gpt-5", made unique among the other specs' names. */ +export function slugify(label: string, taken: ReadonlySet): string { + const base = + label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'spec'; + if (!taken.has(base)) return base; + let n = 2; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +function otherNames(items: t.ConfigValue[], index: number): Set { + return new Set( + items.flatMap((item, i) => + i !== index && isConfigRecord(item) && typeof item.name === 'string' ? [item.name] : [], + ), + ); +} + +/** A spec added in this session (its name isn't one the list was loaded with). */ +function useIsNewSpec(spec: t.ConfigRecord): boolean { + const { savedNames } = useSpecList(); + return !savedNames.has(textOf(spec.name)); +} + +function useRowLabel(path: string, field?: t.SchemaField) { + const localize = useLocalize(); + return { + label: labelForPath(path, localize), + description: field ? describeField(field, localize) : undefined, + }; +} + +/** + * Label*: the name the model menu shows. On a new spec whose name still + * follows the label, the name is derived from it ("GPT 5 mini" → "gpt-5-mini"). + */ +export function LabelField({ field, spec, index, path, onChange, disabled }: t.SpecFieldProps) { + const { items, problems, savedNames } = useSpecList(); + const isNew = useIsNewSpec(spec); + const dataPath = `${path}.label`; + const { label, description } = useRowLabel(dataPath, field); + const name = textOf(spec.name); + const followsLabel = + !savedNames.has(name) && + (name === '' || name === slugify(textOf(spec.label), otherNames(items, index))); + return ( + { + onChange('label', next); + if (followsLabel && next.trim() !== '') { + onChange('name', slugify(next, otherNames(items, index))); + } + }} + /> + } + /> + ); +} + +/** + * The name a spec was loaded with in this edit session: its pending edit's + * origin, or its current name while the list has no pending edit (so it + * follows saves, Discard and scope changes). Undefined for a spec added since. + */ +function useLoadedName(spec: t.ConfigRecord, index: number): string | undefined { + const edit = useContext(CollectionEditsContext)[SPEC_LIST_PATH]; + return edit ? edit.origins[index]?.identity : textOf(spec.name); +} + +/** Name*: the id conversations and `?spec=` links use; renaming a saved spec says what it breaks. */ +export function NameField({ field, spec, index, path, onChange, disabled }: t.SpecFieldProps) { + const localize = useLocalize(); + const { problems, savedNames } = useSpecList(); + const isNew = useIsNewSpec(spec); + const loadedName = useLoadedName(spec, index); + const dataPath = `${path}.name`; + const { label, description } = useRowLabel(dataPath, field); + const problem = problemAt(problems, index, 'name'); + const renamed = + loadedName !== undefined && + loadedName !== '' && + savedNames.has(loadedName) && + textOf(spec.name) !== loadedName; + const warningId = `${toFieldId(dataPath)}-rename`; + return ( + + onChange('name', next)} + /> + {renamed && ( +

+ {localize('com_config_specs_rename_warning', { name: loadedName })} +

+ )} + + } + /> + ); +} + +/** Icon: an image URL or a provider name, with a preview of what the model menu shows. */ +export function IconField({ + field, + spec, + path, + onChange, + disabled, + keyName = 'iconURL', +}: t.SpecIconFieldProps) { + const localize = useLocalize(); + const listId = useId(); + const { options } = useSpecList(); + const dataPath = `${path}.${keyName}`; + const { label, description } = useRowLabel(dataPath, field); + const value = textOf(spec[keyName]); + const title = textOf(keyName === 'groupIcon' ? spec.group : spec.label) || label; + return ( + + + onChange(keyName, next)} + onClear={() => { + onChange(keyName, undefined); + return true; + }} + /> + + {options.endpoints + .filter((option) => !options.customEndpoints.has(option.value)) + .map((option) => ( + + ))} + + + } + /> + ); +} + +/** Group: a group the other specs use, an endpoint name (nests the spec under it), or a new name. */ +export function GroupField({ + field, + spec, + path, + onChange, + disabled, +}: Omit) { + const localize = useLocalize(); + const listId = useId(); + const { options } = useSpecList(); + const dataPath = `${path}.group`; + const { label, description } = useRowLabel(dataPath, field); + return ( + + onChange('group', next)} + onClear={() => { + onChange('group', undefined); + return true; + }} + /> + + {options.groups.map((option) => ( + + ))} + + + } + /> + ); +} + +/** + * Endpoint*: a built-in provider or a custom endpoint; one the configuration + * lacks is flagged. A preset naming only an agent shows the agents endpoint + * LibreChat infers for it. Leaving the agents endpoint clears the agent ID, + * and leaving the assistants endpoints the assistant ID: only those specs + * use (and show) them. + */ +export function EndpointField({ + field, + spec, + index, + path, + onPresetChange, + disabled, +}: t.SpecPresetFieldProps) { + const localize = useLocalize(); + const { options, problems } = useSpecList(); + const dataPath = `${path}.preset.endpoint`; + const { label, description } = useRowLabel(dataPath, field); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const endpoint = specEndpoint(spec); + const problem = problemAt(problems, index, 'preset.endpoint'); + const isNew = useIsNewSpec(spec); + return ( + localize('com_config_specs_not_configured', { name })} + aria-describedby={ + problem?.level === 'warning' ? `${toFieldId(dataPath)}-warning` : undefined + } + onChange={(next) => { + onPresetChange('endpoint', next); + if (next !== EModelEndpoint.agents && preset.agent_id !== undefined) { + onPresetChange('agent_id', undefined); + } + if (!ASSISTANT_ENDPOINTS.has(next) && preset.assistant_id !== undefined) { + onPresetChange('assistant_id', undefined); + } + }} + /> + } + /> + ); +} + +/** Model*: free text with the endpoint's known models suggested (fetched models can't be listed here). */ +export function ModelField({ + field, + spec, + index, + path, + onPresetChange, + disabled, +}: t.SpecPresetFieldProps) { + const localize = useLocalize(); + const listId = useId(); + const { options, problems } = useSpecList(); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const endpoint = specEndpoint(spec); + const source = useOptionSources(endpoint ? `models:${endpoint}` : undefined); + const suggestions = uniqueOptions([...options.modelsFor(endpoint), ...source.options]); + const dataPath = `${path}.preset.model`; + const { label, description } = useRowLabel(dataPath, field); + const problem = problemAt(problems, index, 'preset.model'); + const isNew = useIsNewSpec(spec); + return ( + + onPresetChange('model', next)} + /> + + {suggestions.map((option) => ( + + ))} + + + } + /> + ); +} + +/** Agent ID*: the agent an agents-endpoint spec runs (its model comes from the agent). */ +export function AgentField({ + field, + spec, + index, + path, + onPresetChange, + disabled, +}: t.SpecPresetFieldProps) { + const { problems } = useSpecList(); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const dataPath = `${path}.preset.agent_id`; + const { label, description } = useRowLabel(dataPath, field); + const problem = problemAt(problems, index, 'preset.agent_id'); + const isNew = useIsNewSpec(spec); + return ( + onPresetChange('agent_id', next)} + /> + } + /> + ); +} + +const DEFAULT_KINDS: readonly t.SpecDefaultKind[] = ['none', 'soft', 'default']; + +/** Default for new chats: No, Soft default or Default; choosing one moves it off any other spec. */ +export function DefaultField({ spec, index, path, disabled }: Omit) { + const localize = useLocalize(); + const { problems, actions } = useSpecList(); + const dataPath = `${path}.default`; + const kind: t.SpecDefaultKind = (() => { + if (spec.default === true) return 'default'; + return spec.softDefault === true ? 'soft' : 'none'; + })(); + const problem = + problemAt(problems, index, 'default') ?? problemAt(problems, index, 'softDefault'); + const label = localize('com_config_specs_default'); + return ( + ({ + value, + label: localize(`com_config_specs_default_${value}`), + disabled, + }))} + selected={kind} + onClick={(next) => { + if (disabled || next === kind) return; + actions.setDefault(index, next as t.SpecDefaultKind); + }} + /> + } + /> + ); +} + +/** MCP servers: picked from the servers the configuration defines. */ +export function McpField({ + field, + spec, + path, + onChange, + disabled, +}: Omit) { + const localize = useLocalize(); + const { options } = useSpecList(); + const dataPath = `${path}.mcpServers`; + const { label, description } = useRowLabel(dataPath, field); + const values = Array.isArray(spec.mcpServers) + ? spec.mcpServers.filter((item): item is string => typeof item === 'string') + : []; + return ( + onChange('mcpServers', next)} + onClear={() => { + onChange('mcpServers', undefined); + return true; + }} + addLabel={localize('com_config_specs_mcp_add')} + unknownLabel={(name) => localize('com_config_specs_not_configured', { name })} + aria-label={label} + /> + } + /> + ); +} diff --git a/src/components/configuration/sections/specs/SpecGroup.tsx b/src/components/configuration/sections/specs/SpecGroup.tsx new file mode 100644 index 00000000..2ecac043 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecGroup.tsx @@ -0,0 +1,47 @@ +import { useContext } from 'react'; +import type * as t from '@/types'; +import { useCollapsibleSection } from '../../useCollapsibleSection'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { SettingsSection } from '../../SettingsSection'; +import { CountBadge } from '@/components/shared'; +import { isRowVisible } from '@/utils'; + +/** + * One group of the spec editor (Basics, Model, Capabilities, …): a nested + * settings group that opens for a search hit inside it and follows Expand + * all. While the tab filter is on, a group with no matching row is hidden. + */ +export function SpecGroup({ + title, + description, + paths, + defaultExpanded = false, + configuredCount = 0, + totalCount = 0, + children, +}: t.SpecGroupProps) { + const rowFilter = useContext(RowFilterContext); + const { isExpanded, hasEverExpanded, sectionRef, toggle } = useCollapsibleSection({ + defaultExpanded, + revealPaths: paths, + }); + if (!paths.some((path) => isRowVisible(rowFilter, path))) return null; + return ( + 0 && !isExpanded ? ( + + ) : undefined + } + expanded={isExpanded} + onToggle={toggle} + hasEverExpanded={hasEverExpanded} + > + {children} + + ); +} diff --git a/src/components/configuration/sections/specs/SpecIcon.tsx b/src/components/configuration/sections/specs/SpecIcon.tsx new file mode 100644 index 00000000..cf493d70 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecIcon.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import type * as t from '@/types'; +import { cn } from '@/utils'; + +const IMAGE_URL = /^(https?:\/\/|data:image\/)/i; + +/** An absolute image URL the panel can load (a relative path resolves against LibreChat, not here). */ +export function isImageUrl(value: t.ConfigValue): value is string { + return typeof value === 'string' && IMAGE_URL.test(value.trim()); +} + +/** + * A spec's icon as the model menu shows it: the image at `iconURL`, else the + * first letter of its label (an endpoint name, a relative path or a broken + * image falls back to the letter too). Decorative: the label names the spec. + */ +export function SpecIcon({ iconURL, label, size = 'sm' }: t.SpecIconProps) { + const [failed, setFailed] = useState(null); + const src = isImageUrl(iconURL) ? iconURL.trim() : null; + const box = size === 'sm' ? 'size-5 text-[0.6875rem]' : 'size-8 text-sm'; + if (src && failed !== src) { + return ( + setFailed(src)} + className={cn(box, 'shrink-0 rounded-md object-cover')} + /> + ); + } + const initial = label.trim().charAt(0).toUpperCase() || '?'; + return ( + + ); +} diff --git a/src/components/configuration/sections/specs/SpecList.tsx b/src/components/configuration/sections/specs/SpecList.tsx new file mode 100644 index 00000000..dd6f2fb1 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecList.tsx @@ -0,0 +1,241 @@ +import { useRef, useMemo, useState, useEffect, useCallback, useContext } from 'react'; +import type * as t from '@/types'; +import { ListOwnershipNotice } from '../../renderer/Collections'; +import { ArrayObjectField } from '../../fields/ArrayObjectField'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { SpecLeading, SpecSummary, specTitle } from './SpecSummary'; +import { useLocalize, useValuesScope, useFieldState } from '@/hooks'; +import { CountBadge, DecorativeIcon } from '@/components/shared'; +import { hasPathOrDescendant } from '../../renderer/paths'; +import { SPEC_LIST_PATH } from './validation'; +import { SpecListContext } from './context'; +import { isConfigRecord } from '@/utils'; +import { SpecEditor } from './SpecEditor'; +import { SpecNote } from './SpecNote'; + +function namesOf(list: t.ConfigValue): Set { + const names = new Set(); + for (const spec of Array.isArray(list) ? list : []) { + if (isConfigRecord(spec) && typeof spec.name === 'string') names.add(spec.name); + } + return names; +} + +/** The list with one spec made the default (or soft default, or neither) and that role taken off the others. */ +export function withDefault( + items: t.ConfigValue[], + index: number, + kind: t.SpecDefaultKind, +): t.ConfigValue[] { + return items.map((spec, i) => { + if (!isConfigRecord(spec)) return spec; + const { default: isDefault, softDefault: isSoft, ...rest } = spec; + if (i === index) { + if (kind === 'default') return { ...rest, default: true }; + if (kind === 'soft') return { ...rest, softDefault: true }; + return rest; + } + if (kind === 'default' && isDefault === true) { + return { ...rest, ...(isSoft !== undefined && { softDefault: isSoft }) }; + } + if (kind === 'soft' && isSoft === true) { + return { ...rest, ...(isDefault !== undefined && { default: isDefault }) }; + } + return spec; + }); +} + +function hasPendingEdit(editedValues: t.FlatConfigMap | undefined, path: string): boolean { + return Object.keys(editedValues ?? {}).some((key) => key === path || key.startsWith(`${path}.`)); +} + +/** + * The spec list (MS-1/5/6): sortable cards (list order is menu order), each + * with its icon, label, name, endpoint · model, group and badges, opening the + * spec editor. In Base, a list the panel stores says it overrides + * librechat.yaml and offers to revert it (D2). In a profile, the list Base + * gives is shown read-only until "Customize list for …" takes a copy, since a + * profile's list replaces Base's whole. + */ +export function SpecList({ + listField, + items, + options, + problems, + onChange, + onResetField, + onDiscardField, + editedValues, + disabled, + permissions, + dbOverridePaths, + pendingResets, + editSessionId, + showConfiguredOnly, +}: t.SpecListProps) { + const localize = useLocalize(); + const scope = useValuesScope(); + const rowFilter = useContext(RowFilterContext); + const listState = useFieldState(SPEC_LIST_PATH); + const addRef = useRef<(() => void) | null>(null); + const addButtonRef = useRef(null); + const [customizing, setCustomizing] = useState(false); + useEffect(() => setCustomizing(false), [editSessionId, scope?.name]); + /** "Customize list" disappears once used: focus moves on to Add spec. */ + useEffect(() => { + if (customizing) addButtonRef.current?.focus(); + }, [customizing]); + + const canEdit = !disabled && permissions?.canEdit !== false; + const isPendingReset = Boolean(pendingResets?.has(SPEC_LIST_PATH)) || listState.isPendingReset; + const ownsList = + !isPendingReset && + (hasPathOrDescendant(SPEC_LIST_PATH, dbOverridePaths) || listState.isOverride); + const isPending = hasPendingEdit(editedValues, SPEC_LIST_PATH) && !isPendingReset; + const inheritedOnly = scope != null && !ownsList && !isPending && !customizing; + const editable = canEdit && !inheritedOnly && !isPendingReset; + const savedNames = useMemo(() => namesOf(listState.effective), [listState.effective]); + + const setDefault = useCallback( + (index: number, kind: t.SpecDefaultKind) => + onChange(SPEC_LIST_PATH, withDefault(items, index, kind)), + [items, onChange], + ); + const contextValue = useMemo( + () => ({ + items, + problems, + options, + actions: { setDefault }, + savedNames, + configuredOnly: Boolean(showConfiguredOnly), + }), + [items, problems, options, setDefault, savedNames, showConfiguredOnly], + ); + const renderFields = useCallback( + (fields, value, path, change, _addField, sessionId, fieldsDisabled) => ( + + ), + [], + ); + + const scopeName = scope?.name ?? ''; + const revert = canEdit && onResetField ? () => onResetField(SPEC_LIST_PATH) : undefined; + + const notice = (() => { + if (isPendingReset) { + return ( + onDiscardField(SPEC_LIST_PATH)} + > + {localize('com_config_ui_undo')} + + ) + } + > + {localize( + scope ? 'com_config_specs_pending_revert_scope' : 'com_config_specs_pending_revert', + )} + + ); + } + if (inheritedOnly) { + return ( + setCustomizing(true)} + > + {localize('com_config_specs_customize', { name: scopeName })} + + ) + } + > + {localize('com_config_specs_inherited', { name: scopeName })} + + ); + } + if (ownsList) return ; + if (scope) { + return ( + + {localize('com_config_specs_customizing', { name: scopeName })} + + ); + } + if (isPending) { + return {localize('com_config_specs_will_own')}; + } + return null; + })(); + + const addButton = editable ? ( + + ) : undefined; + + return ( +
+
+

+ {localize('com_config_specs_list')} + +

+ {addButton} +

{localize('com_config_specs_list_desc')}

+
+ {!rowFilter && notice} + + onChange(SPEC_LIST_PATH, next)} + onEntryChange={(index, next) => onChange(`${SPEC_LIST_PATH}.${index}`, next)} + disabled={!editable} + hideAddButton + addTriggerRef={addRef} + renderFields={renderFields} + getEntryTitle={(item) => specTitle(item, localize)} + renderSummary={(item, index) => } + renderLeading={(item) => } + stackedSummary + reorderable={editable} + emptyLabel={localize( + editable ? 'com_config_specs_empty' : 'com_config_specs_empty_readonly', + )} + editSessionId={editSessionId} + /> + +
+ ); +} diff --git a/src/components/configuration/sections/specs/SpecNote.tsx b/src/components/configuration/sections/specs/SpecNote.tsx new file mode 100644 index 00000000..7d4e6141 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecNote.tsx @@ -0,0 +1,36 @@ +import type * as t from '@/types'; +import { DecorativeIcon } from '@/components/shared'; +import { cn } from '@/utils'; + +const TONE_CLASSES: Record = { + info: 'settings-banner', + warning: + 'flex items-start gap-2.5 rounded-lg border border-solid border-(--settings-warn-mark) bg-(--settings-warn-bg) px-3 py-2.5 text-[0.8125rem] leading-5 text-(--settings-warn-fg)', + preview: + 'flex items-start gap-2.5 rounded-lg border border-solid border-(--cui-color-stroke-default) bg-(--cui-color-background-muted) px-3 py-2.5 text-[0.8125rem] leading-5 text-(--cui-color-text-default)', +}; + +const TONE_ICONS: Record = { + info: 'info-in-circle', + warning: 'warning', + preview: 'info-in-circle', +}; + +/** + * A note between the specs section's rows: what a list or setting means, a + * warning about a combination LibreChat handles badly, or what users will see, + * with optional actions. + */ +export function SpecNote({ tone, icon, action, children }: t.SpecNoteProps) { + return ( +
+ + {icon ?? } + +
+

{children}

+ {action && {action}} +
+
+ ); +} diff --git a/src/components/configuration/sections/specs/SpecSummary.tsx b/src/components/configuration/sections/specs/SpecSummary.tsx new file mode 100644 index 00000000..2e8cc1ef --- /dev/null +++ b/src/components/configuration/sections/specs/SpecSummary.tsx @@ -0,0 +1,104 @@ +import type * as t from '@/types'; +import { isAgentSpec, problemsOf, specEndpoint } from './validation'; +import { isConfigRecord } from '@/utils'; +import { useSpecList } from './context'; +import { useLocalize } from '@/hooks'; +import { SpecIcon } from './SpecIcon'; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value.trim() : ''; +} + +/** A spec's display title: its label, else its name, else "New spec". */ +export function specTitle(spec: t.ConfigValue, localize: t.LocalizeFn): string { + if (!isConfigRecord(spec)) return localize('com_config_specs_new'); + return textOf(spec.label) || textOf(spec.name) || localize('com_config_specs_new'); +} + +/** The icon before a spec's title in the list. */ +export function SpecLeading({ spec }: { spec: t.ConfigValue }) { + const localize = useLocalize(); + const record = isConfigRecord(spec) ? spec : {}; + return ; +} + +function SummaryChip({ + kind, + label, + description, +}: { + kind: 'changed' | 'default' | 'hidden' | 'error' | 'warning'; + label: string; + description?: string; +}) { + return ( + + {label} + {description && {`, ${description}`}} + + ); +} + +/** + * A collapsed spec row: Default / Soft default / Hidden badges, its problems, + * then its name (when the label differs), endpoint · model and group, muted. + */ +export function SpecSummary({ spec, index }: t.SpecSummaryProps) { + const localize = useLocalize(); + const { problems } = useSpecList(); + if (!isConfigRecord(spec)) return null; + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const own = problemsOf(problems, index); + const errors = own.filter((problem) => problem.level === 'error').length; + const warnings = own.length - errors; + const name = textOf(spec.name); + const label = textOf(spec.label); + const target = isAgentSpec(spec) ? textOf(preset.agent_id) : textOf(preset.model); + const details = [ + name && name !== label ? name : '', + [specEndpoint(spec), target].filter(Boolean).join(' · '), + textOf(spec.group) + ? localize('com_config_specs_summary_group', { group: textOf(spec.group) }) + : '', + ].filter(Boolean); + return ( + + + {spec.default === true && ( + + )} + {spec.softDefault === true && ( + + )} + {spec.showInMenu === false && ( + + )} + {errors > 0 && ( + + )} + {errors === 0 && warnings > 0 && ( + + )} + + {details.length > 0 && {details.join(' · ')}} + + ); +} diff --git a/src/components/configuration/sections/specs/SpecsSettings.tsx b/src/components/configuration/sections/specs/SpecsSettings.tsx new file mode 100644 index 00000000..bbfc04ff --- /dev/null +++ b/src/components/configuration/sections/specs/SpecsSettings.tsx @@ -0,0 +1,319 @@ +import { useContext } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { useLocalize, useFieldState, useValuesScope } from '@/hooks'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { DecorativeIcon, StateChip } from '@/components/shared'; +import { cn, isRowVisible, isConfigRecord } from '@/utils'; +import { hasPathOrDescendant } from '../../renderer/paths'; +import { FieldProvenance } from '../../values'; +import { SettingsRow } from '../../SettingsRow'; +import { endpointGroups } from './groups'; +import { ChipPicker } from './ChipPicker'; +import { ConfigRow } from '../../ConfigRow'; +import { SpecNote } from './SpecNote'; + +const ENFORCE = 'modelSpecs.enforce'; +const PRIORITIZE = 'modelSpecs.prioritize'; +const ADDED = 'modelSpecs.addedEndpoints'; +const MODEL_SELECT = 'interface.modelSelect'; + +const MODES: readonly t.SpecSelectionMode[] = ['enforce', 'prioritize', 'endpoints']; + +/** `enforce`/`prioritize` each mode stands for. */ +const MODE_VALUES: Record = { + enforce: { enforce: true, prioritize: true }, + prioritize: { enforce: false, prioritize: true }, + endpoints: { enforce: false, prioritize: false }, +}; + +function asBool(value: t.ConfigValue, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function stringsOf(value: t.ConfigValue): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +/** + * Whether the endpoint menu shows: `interface.modelSelect` when set, else + * what LibreChat derives with specs configured (on only with added endpoints). + */ +function modelSelectOn(value: t.ConfigValue, specCount: number, added: number): boolean { + if (typeof value === 'boolean') return value; + return specCount > 0 ? added > 0 : true; +} + +/** "Users will see: 3 specs, plus OpenAI and Agents in the endpoint menu. 1 more is hidden." */ +export function previewText( + localize: t.LocalizeFn, + { + items, + mode, + menuOn, + added, + }: { items: t.ConfigValue[]; mode: t.SpecSelectionMode; menuOn: boolean; added: string[] }, +): string { + if (items.length === 0) return localize('com_config_specs_preview_none'); + const hidden = items.filter((spec) => isConfigRecord(spec) && spec.showInMenu === false).length; + const specs = localize('com_config_specs_preview_specs', { count: items.length - hidden }); + const sentence = (() => { + if (mode === 'enforce') return localize('com_config_specs_preview_enforce', { specs }); + if (!menuOn) return localize('com_config_specs_preview_menu_off', { specs }); + if (added.length > 0) { + return localize('com_config_specs_preview_added', { specs, endpoints: added.join(', ') }); + } + return localize('com_config_specs_preview_all', { specs }); + })(); + if (hidden === 0) return sentence; + return `${sentence} ${localize('com_config_specs_preview_hidden', { count: hidden })}`; +} + +/** Enforce and Prioritize as one setting: set by mode, with their pending and overridden paths. */ +function useModeState( + onChange: t.FieldRendererProps['onChange'], + onDiscard?: (path: string) => void, +) { + const enforce = useFieldState(ENFORCE); + const prioritize = useFieldState(PRIORITIZE); + const states: Array<[string, t.FieldState]> = [ + [ENFORCE, enforce], + [PRIORITIZE, prioritize], + ]; + /** + * Sets one flag to `desired` with the fewest stored changes: back to its + * saved value drops the pending edit; back to what lies beneath removes the + * override; anything else is an edit. + */ + const apply = (path: string, state: t.FieldState, fallback: boolean, desired: boolean) => { + const saved = asBool(state.effective, fallback); + const lower = state.isOverride ? asBool(state.revertTarget?.value, fallback) : saved; + if (desired === saved && !state.isPendingReset) { + if (state.isEdited) { + if (onDiscard) onDiscard(path); + else onChange(path, saved); + } + return; + } + onChange(path, state.isOverride && desired === lower ? undefined : desired); + }; + const setMode = (mode: t.SpecSelectionMode) => { + const values = MODE_VALUES[mode]; + apply(ENFORCE, enforce, false, values.enforce); + apply(PRIORITIZE, prioritize, true, values.prioritize); + }; + const overridden = states.filter(([, state]) => state.isOverride).map(([path]) => path); + const edited = states.filter(([, state]) => state.isEdited).map(([path]) => path); + return { setMode, overridden, edited }; +} + +/** + * "How users pick models" (MS-3): Specs only, Specs first or Endpoints first + * in place of the raw Enforce and Prioritize switches; the endpoints listed + * next to the specs, picked from the configured ones; and a line saying what + * users will see. + */ +export function SpecsSettings({ + items, + options, + getValue, + onChange, + onResetField, + onDiscardField, + disabled, + permissions, + configuredPaths, + dbOverridePaths, + pendingResets, + showConfiguredOnly, +}: t.SpecsSettingsProps) { + const localize = useLocalize(); + const navigate = useNavigate(); + const scope = useValuesScope(); + const rowFilter = useContext(RowFilterContext); + const { setMode, overridden, edited } = useModeState(onChange, onDiscardField); + const enforce = asBool(getValue(ENFORCE, undefined), false); + const prioritize = asBool(getValue(PRIORITIZE, undefined), true); + const mode: t.SpecSelectionMode = (() => { + if (enforce) return 'enforce'; + return prioritize ? 'prioritize' : 'endpoints'; + })(); + const added = stringsOf(getValue(ADDED, undefined)); + const menuOn = modelSelectOn(getValue(MODEL_SELECT, undefined), items.length, added.length); + const canEdit = !disabled && permissions?.canEdit !== false; + const isShown = (path: string) => + isRowVisible(rowFilter, path) && + (!showConfiguredOnly || hasPathOrDescendant(path, configuredPaths)); + const showsMode = isShown(ENFORCE) || isShown(PRIORITIZE); + + const goToModelSelect = () => + navigate({ + to: '/configuration', + search: (prev: t.ConfigSearchParams) => ({ ...prev, tab: 'features', field: MODEL_SELECT }), + }); + const modelSelectLink = ( + + ); + + const modeChips = (): ReactNode => { + if (edited.length > 0) { + return ( + edited.forEach((path) => onDiscardField(path)) : undefined} + undoLabel={localize('com_config_ui_undo_change', { + name: localize('com_config_specs_mode'), + })} + /> + ); + } + return ; + }; + + const modeRevert = + canEdit && onResetField && overridden.length > 0 && edited.length === 0 ? ( + + ) : null; + + const addedState = useFieldState(ADDED); + const isDbOverride = hasPathOrDescendant(ADDED, dbOverridePaths); + const isConfigured = hasPathOrDescendant(ADDED, configuredPaths); + + return ( + <> + {showsMode && ( + 0 ? 'edited' : undefined} + chips={modeChips()} + actions={modeRevert} + control={ +
+ {MODES.map((value) => ( + + ))} +
+ } + /> + )} + {showsMode && enforce && !prioritize && ( + {localize('com_config_specs_mode_mixed')} + )} + {showsMode && mode === 'enforce' && menuOn && ( + + {localize('com_config_specs_enforce_menu_on')} + + )} + {isShown(ADDED) && ( + + onChange(ADDED, next)} + onClear={() => { + if (isDbOverride) { + onChange(ADDED, undefined); + return true; + } + /** librechat.yaml (or, in a profile, Base) sets the list: it can't be emptied here (D3). */ + if (addedState.effective !== undefined) return false; + onChange(ADDED, undefined); + return true; + }} + addLabel={localize('com_config_specs_added_add')} + unknownLabel={(name) => localize('com_config_specs_not_configured', { name })} + aria-label={localize('com_config_specs_added')} + /> + + )} + {isShown(ADDED) && !menuOn && added.length > 0 && mode !== 'enforce' && ( + + {localize('com_config_specs_added_menu_off')} + + )} + {!rowFilter && !showConfiguredOnly && ( + }> + {localize('com_config_specs_preview_label')}{' '} + {previewText(localize, { + items, + mode, + menuOn, + added: added.map( + (name) => options.endpoints.find((option) => option.value === name)?.label ?? name, + ), + })} + + )} + + ); +} diff --git a/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx b/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx new file mode 100644 index 00000000..3204aa2d --- /dev/null +++ b/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx @@ -0,0 +1,487 @@ +import { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { configSchema } from 'librechat-data-provider'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + baseConfigFixture, + baseLayersFixture, + scopeLayersFixture, + valuesDataFixture, +} from '@/test/layers'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { ModelSpecsRenderer } from '../ModelSpecsRenderer'; +import { extractSchemaTree } from '@/server/config'; +import { previewText } from '../SpecsSettings'; +import { slugify } from '../SpecFields'; +import { createPathIndex } from '@/utils'; +import { withDefault } from '../SpecList'; + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + useLocalize: () => (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, +})); + +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => vi.fn(), +})); + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const specFields = tree.find((field) => field.key === 'modelSpecs')?.children ?? []; + +const SPECS: t.ConfigRecord[] = [ + { + name: 'gpt', + label: 'GPT', + default: true, + preset: { endpoint: 'openAI', model: 'gpt-4o' }, + }, + { + name: 'claude', + label: 'Claude', + showInMenu: false, + preset: { endpoint: 'anthropic', model: 'claude-sonnet-4-5' }, + }, +]; + +function renderSpecs( + modelSpecs: t.ConfigRecord, + { + onChange = vi.fn(), + data, + showConfiguredOnly, + }: { + onChange?: t.FieldRendererProps['onChange']; + data?: t.ConfigValuesData; + showConfiguredOnly?: boolean; + } = {}, +) { + const values = data ?? valuesDataFixture(baseLayersFixture()); + const wrap = (children: ReactNode) => ( + + {children} + + ); + const ui = ( + { + const key = path.slice('modelSpecs.'.length); + return key in modelSpecs ? modelSpecs[key] : fallback; + }} + onChange={onChange} + onResetField={vi.fn()} + onDiscardField={vi.fn()} + permissions={{ canView: true, canEdit: true, canAssign: true }} + showConfiguredOnly={showConfiguredOnly} + /> + ); + return { onChange, ...render(wrap(ui)) }; +} + +describe('ModelSpecsRenderer', () => { + it('lists each spec by label with its badges and endpoint · model', () => { + renderSpecs({ list: SPECS }); + const cards = document.querySelectorAll('[data-entry-card]'); + expect(cards).toHaveLength(2); + expect(within(cards[0] as HTMLElement).getByText('GPT')).toBeInTheDocument(); + expect( + within(cards[0] as HTMLElement).getByText('com_config_specs_badge_default'), + ).toBeInTheDocument(); + expect(within(cards[0] as HTMLElement).getByText('gpt · openAI · gpt-4o')).toBeInTheDocument(); + expect( + within(cards[1] as HTMLElement).getByText('com_config_specs_badge_hidden'), + ).toBeInTheDocument(); + }); + + it('opens a new spec with Endpoint and Model up front, marked required', () => { + const { onChange } = renderSpecs({ list: SPECS }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_specs_add' })); + expect(onChange).toHaveBeenCalledWith('modelSpecs.list', [...SPECS, {}]); + }); + + it('shows the endpoint as a choice of configured endpoints and the model as text', () => { + renderSpecs({ list: SPECS }); + const toggle = document.querySelectorAll( + '[data-entry-card] button[aria-expanded]', + )[0]; + fireEvent.click(toggle); + const endpoint = document.getElementById( + 'modelSpecs-list-0-preset-endpoint', + ) as HTMLSelectElement; + expect(endpoint.tagName).toBe('SELECT'); + expect(endpoint.value).toBe('openAI'); + const values = [...endpoint.querySelectorAll('option')].map((option) => option.value); + expect(values).toEqual( + expect.arrayContaining(['openAI', 'anthropic', 'agents', 'GPT', 'Claude']), + ); + expect(values).not.toContain('custom'); + expect( + (document.getElementById('modelSpecs-list-0-preset-model') as HTMLInputElement).value, + ).toBe('gpt-4o'); + }); + + it('moves the default to the spec it is set on (one default per list)', () => { + expect( + withDefault(SPECS, 1, 'default').map((spec) => (spec as t.ConfigRecord).default), + ).toEqual([undefined, true]); + const soft = withDefault( + [{ name: 'a', softDefault: true, default: true }, { name: 'b' }], + 1, + 'soft', + ); + expect(soft).toEqual([ + { name: 'a', default: true }, + { name: 'b', softDefault: true }, + ]); + expect(withDefault(SPECS, 0, 'none')[0]).not.toHaveProperty('default'); + const explicit = [{ name: 'a', default: false }, { name: 'b' }]; + expect(withDefault(explicit, 1, 'default')[0]).toBe(explicit[0]); + }); + + it('maps "How users pick models" onto enforce and prioritize', () => { + const { onChange } = renderSpecs({ list: SPECS, enforce: false, prioritize: true }); + expect(screen.getByLabelText('com_config_specs_mode_prioritize')).toBeChecked(); + fireEvent.click(screen.getByLabelText('com_config_specs_mode_enforce')); + expect(onChange).toHaveBeenCalledWith('modelSpecs.enforce', true); + expect(onChange).not.toHaveBeenCalledWith('modelSpecs.prioritize', expect.anything()); + fireEvent.click(screen.getByLabelText('com_config_specs_mode_endpoints')); + expect(onChange).toHaveBeenCalledWith('modelSpecs.prioritize', false); + }); + + it('adds endpoints next to the specs only from the configured ones (no blank or typed names)', () => { + const { onChange } = renderSpecs({ list: SPECS, addedEndpoints: ['openAI', 'Gone'] }); + const picker = document.getElementById('modelSpecs-addedEndpoints-add') as HTMLSelectElement; + const options = [...picker.querySelectorAll('option')].map((option) => option.value); + expect(options).not.toContain(''); + expect(options).not.toContain('openAI'); + expect(options).toContain('agents'); + expect(screen.getByText('com_config_specs_not_configured {"name":"Gone"}')).toBeInTheDocument(); + fireEvent.change(picker, { target: { value: 'agents' } }); + expect(onChange).toHaveBeenCalledWith('modelSpecs.addedEndpoints', [ + 'openAI', + 'Gone', + 'agents', + ]); + fireEvent.click( + screen.getByRole('button', { name: /com_config_specs_chip_remove \{"name":"Gone"\}/ }), + ); + expect(onChange).toHaveBeenCalledWith('modelSpecs.addedEndpoints', ['openAI']); + }); + + it('flags spec problems on the row and at the field', () => { + renderSpecs({ + list: [...SPECS, { name: 'gpt', label: 'Copy', preset: { endpoint: 'Nope', model: 'm' } }], + }); + const card = document.querySelectorAll('[data-entry-card]')[2]; + expect(within(card).getByText('com_config_specs_badge_errors {"count":1}')).toBeInTheDocument(); + fireEvent.click(within(card).getAllByRole('button', { expanded: false })[0]); + expect( + within(card).getByText(/com_config_issue_spec_duplicate_name/, { + selector: '[role="alert"]', + }), + ).toBeInTheDocument(); + expect(within(card).getByText(/com_config_issue_spec_unknown_endpoint/)).toBeInTheDocument(); + }); + + it('keeps a new spec\'s required fields under "Set values only"', () => { + renderSpecs({ list: [...SPECS, {}] }, { showConfiguredOnly: true }); + const card = document.querySelectorAll('[data-entry-card]')[2]; + fireEvent.click(within(card).getAllByRole('button', { expanded: false })[0]); + for (const id of ['label', 'name', 'preset-endpoint', 'preset-model']) { + expect(document.getElementById(`modelSpecs-list-2-${id}`)).toBeInTheDocument(); + } + expect(document.getElementById('modelSpecs-list-2-description')).toBeNull(); + const saved = document.querySelectorAll('[data-entry-card]')[0]; + fireEvent.click(within(saved).getAllByRole('button', { expanded: false })[0]); + expect(document.getElementById('modelSpecs-list-0-preset-model')).toBeInTheDocument(); + expect(document.getElementById('modelSpecs-list-0-description')).toBeNull(); + }); + + it('warns about renaming a spec against the name it was saved with, after an earlier save', () => { + const spec = (name: string): t.ConfigRecord => ({ + name, + label: 'Spec', + preset: { endpoint: 'openAI', model: 'gpt-4o' }, + }); + const savedAs = (name: string) => + valuesDataFixture( + baseLayersFixture( + baseConfigFixture({ dbOverrides: { modelSpecs: { list: [spec(name)] } } }), + ), + ); + const ui = (list: t.ConfigRecord[], data: t.ConfigValuesData, edits: t.CollectionEdits) => ( + + + (path === 'modelSpecs.list' ? list : fallback)} + onChange={vi.fn()} + onResetField={vi.fn()} + onDiscardField={vi.fn()} + permissions={{ canView: true, canEdit: true, canAssign: true }} + /> + + + ); + const expand = () => { + const toggle = document.querySelector( + '[data-entry-card] button[aria-expanded="false"]', + ); + if (toggle) fireEvent.click(toggle); + }; + const renamedFrom = (identity: string): t.CollectionEdits => ({ + 'modelSpecs.list': { + baseline: [spec(identity)], + origins: [{ identity, baseline: spec(identity) }], + }, + }); + const { rerender } = render(ui([spec('a')], savedAs('a'), {})); + expand(); + rerender(ui([spec('b')], savedAs('a'), renamedFrom('a'))); + expand(); + expect(screen.getByText('com_config_specs_rename_warning {"name":"a"}')).toBeInTheDocument(); + rerender(ui([spec('b')], savedAs('b'), {})); + expand(); + expect(screen.queryByText(/com_config_specs_rename_warning/)).toBeNull(); + rerender(ui([spec('c')], savedAs('b'), renamedFrom('b'))); + expand(); + expect(screen.getByText('com_config_specs_rename_warning {"name":"b"}')).toBeInTheDocument(); + }); + + it('shows the inherited list read-only in a profile until it is customized', () => { + const base = baseConfigFixture({ dbOverrides: { modelSpecs: { list: SPECS } } }); + const data = valuesDataFixture(scopeLayersFixture(undefined, base)); + renderSpecs({ list: SPECS }, { data }); + expect(screen.queryByRole('button', { name: 'com_config_specs_add' })).not.toBeInTheDocument(); + expect(screen.queryAllByRole('button', { name: /com_config_ctl_entry_move/ })).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: /com_config_specs_customize/ })); + expect(screen.getByRole('button', { name: 'com_config_specs_add' })).toHaveFocus(); + expect(screen.getByText(/com_config_specs_customizing/)).toBeInTheDocument(); + }); + + it('says an inherited endpoint list cannot be emptied in a profile (D3)', () => { + const base = baseConfigFixture({ dbOverrides: { modelSpecs: { addedEndpoints: ['openAI'] } } }); + const data = valuesDataFixture(scopeLayersFixture(undefined, base)); + const { onChange } = renderSpecs({ list: SPECS, addedEndpoints: ['openAI'] }, { data }); + fireEvent.click(screen.getByRole('button', { name: /com_config_specs_chip_remove/ })); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText('com_config_ctl_clear_unavailable')).toBeInTheDocument(); + }); + + it('clears the agent ID when an agents spec moves to another endpoint', () => { + const agent: t.ConfigRecord = { + name: 'helper', + label: 'Helper', + preset: { endpoint: 'agents', agent_id: 'agent_1' }, + }; + const { onChange } = renderSpecs({ list: [agent] }); + fireEvent.click(document.querySelector('[data-entry-card] button[aria-expanded="false"]')!); + expect(document.getElementById('modelSpecs-list-0-preset-agent_id')).not.toBeNull(); + fireEvent.change(document.getElementById('modelSpecs-list-0-preset-endpoint')!, { + target: { value: 'openAI' }, + }); + expect(onChange).toHaveBeenLastCalledWith('modelSpecs.list.0', { + ...agent, + preset: { endpoint: 'openAI' }, + }); + }); + + it('keeps an agent ID a non-agents spec still stores visible, so it can be removed', () => { + renderSpecs({ + list: [ + { name: 'x', label: 'X', preset: { endpoint: 'openAI', model: 'm', agent_id: 'old' } }, + ], + }); + fireEvent.click(document.querySelector('[data-entry-card] button[aria-expanded="false"]')!); + expect(document.getElementById('modelSpecs-list-0-preset-agent_id')).not.toBeNull(); + }); + + it('keeps an expanded spec open on the spec itself when it is moved', () => { + function Stateful() { + const [list, setList] = useState(SPECS); + return ( + + (path === 'modelSpecs.list' ? list : fallback)} + onChange={(path, value) => { + if (path === 'modelSpecs.list') setList(value); + }} + onResetField={vi.fn()} + onDiscardField={vi.fn()} + permissions={{ canView: true, canEdit: true, canAssign: true }} + /> + + ); + } + render(); + const toggle = (index: number) => + document + .querySelectorAll('[data-entry-card]') + [index].querySelector('button[aria-expanded]') as HTMLButtonElement; + fireEvent.click(toggle(1)); + expect(document.getElementById('modelSpecs-list-1-preset-model')).toHaveValue( + 'claude-sonnet-4-5', + ); + fireEvent.click( + screen.getByRole('button', { name: 'com_config_ctl_entry_move_up {"name":"Claude"}' }), + ); + expect(toggle(0)).toHaveAttribute('aria-expanded', 'true'); + expect(toggle(1)).toHaveAttribute('aria-expanded', 'false'); + expect(document.getElementById('modelSpecs-list-0-preset-model')).toHaveValue( + 'claude-sonnet-4-5', + ); + }); + + it('clears the assistant ID when an assistants spec moves to another endpoint', () => { + const assistant: t.ConfigRecord = { + name: 'helper', + label: 'Helper', + preset: { endpoint: 'assistants', model: 'gpt-4o', assistant_id: 'asst_1' }, + }; + const { onChange } = renderSpecs({ list: [assistant] }); + fireEvent.click(document.querySelector('[data-entry-card] button[aria-expanded="false"]')!); + expect(document.getElementById('modelSpecs-list-0-preset-assistant_id')).not.toBeNull(); + fireEvent.change(document.getElementById('modelSpecs-list-0-preset-endpoint')!, { + target: { value: 'azureAssistants' }, + }); + expect(onChange).toHaveBeenLastCalledWith('modelSpecs.list.0', { + ...assistant, + preset: { endpoint: 'azureAssistants', model: 'gpt-4o', assistant_id: 'asst_1' }, + }); + fireEvent.change(document.getElementById('modelSpecs-list-0-preset-endpoint')!, { + target: { value: 'openAI' }, + }); + expect(onChange).toHaveBeenLastCalledWith('modelSpecs.list.0', { + ...assistant, + preset: { endpoint: 'openAI', model: 'gpt-4o' }, + }); + }); + + it('shows the assistant ID once, in the Model group, never among the parameters', () => { + renderSpecs({ + list: [ + { + name: 'helper', + label: 'Helper', + preset: { endpoint: 'assistants', model: 'gpt-4o', assistant_id: 'asst_1', topK: 3 }, + }, + ], + }); + fireEvent.click(document.querySelector('[data-entry-card] button[aria-expanded="false"]')!); + fireEvent.click(screen.getByRole('button', { name: /com_config_specs_group_params/ })); + expect(document.getElementById('modelSpecs-list-0-preset-topK')).not.toBeNull(); + expect(document.querySelectorAll('#modelSpecs-list-0-preset-assistant_id')).toHaveLength(1); + }); + + it('lists only the parameters the tab filter matches', () => { + const filtered = (paths: string[]) => + render( + + + (path === 'modelSpecs.list' ? SPECS : fallback)} + onChange={vi.fn()} + permissions={{ canView: true, canEdit: true, canAssign: true }} + /> + + , + ); + const first = filtered(['modelSpecs.list.0.preset.temperature']); + const params = screen.getByRole('button', { name: /com_config_specs_group_params/ }); + if (params.getAttribute('aria-expanded') === 'false') fireEvent.click(params); + expect(document.getElementById('modelSpecs-list-0-preset-temperature')).not.toBeNull(); + expect(document.getElementById('modelSpecs-list-0-preset-top_p')).toBeNull(); + expect(screen.queryByRole('button', { name: 'com_config_specs_raw' })).toBeNull(); + first.unmount(); + filtered(['modelSpecs.list.0.preset.model']); + expect(document.getElementById('modelSpecs-list-0-preset-model')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /com_config_specs_group_params/ })).toBeNull(); + }); + + it('keeps settings the panel does not know visible under "Set values only"', () => { + renderSpecs( + { + list: [ + { + name: 'gpt', + label: 'GPT', + futureSetting: true, + preset: { endpoint: 'openAI', model: 'gpt-4o', legacyKey: 1 }, + }, + ], + }, + { showConfiguredOnly: true }, + ); + fireEvent.click(document.querySelector('[data-entry-card] button[aria-expanded="false"]')!); + const advanced = screen.getByRole('button', { name: /com_config_specs_group_advanced/ }); + fireEvent.click(advanced); + expect(screen.getByText('futureSetting')).toBeInTheDocument(); + expect(screen.getByText('legacyKey')).toBeInTheDocument(); + }); + + it('shows the agents endpoint LibreChat infers for a preset naming only an agent', () => { + renderSpecs( + { list: [{ name: 'helper', label: 'Helper', preset: { agent_id: 'agent_1' } }] }, + { showConfiguredOnly: true }, + ); + fireEvent.click(document.querySelector('[data-entry-card] button[aria-expanded="false"]')!); + const endpoint = document.getElementById( + 'modelSpecs-list-0-preset-endpoint', + ) as HTMLSelectElement; + expect(endpoint.value).toBe('agents'); + expect(endpoint.getAttribute('aria-invalid')).not.toBe('true'); + }); + + it('offers an Anthropic spec only Anthropic parameters', () => { + renderSpecs({ list: SPECS }); + const card = document.querySelectorAll('[data-entry-card]')[1]; + fireEvent.click(within(card).getAllByRole('button', { expanded: false })[0]); + fireEvent.click(within(card).getByRole('button', { name: /com_config_specs_group_params/ })); + expect(document.getElementById('modelSpecs-list-1-preset-topK')).not.toBeNull(); + expect(document.getElementById('modelSpecs-list-1-preset-top_p')).toBeNull(); + expect(document.getElementById('modelSpecs-list-1-preset-frequency_penalty')).toBeNull(); + }); +}); + +describe('spec helpers', () => { + it('derives a unique name from a label', () => { + expect(slugify('GPT 5 mini!', new Set())).toBe('gpt-5-mini'); + expect(slugify('GPT', new Set(['gpt', 'gpt-2']))).toBe('gpt-3'); + expect(slugify(' ', new Set())).toBe('spec'); + }); + + it('says what users will see', () => { + const localize: t.LocalizeFn = (key, params) => `${key}${params ? JSON.stringify(params) : ''}`; + const items = [{ name: 'a' }, { name: 'b', showInMenu: false }]; + expect( + previewText(localize, { items, mode: 'prioritize', menuOn: true, added: ['OpenAI'] }), + ).toBe( + 'com_config_specs_preview_added{"specs":"com_config_specs_preview_specs{\\"count\\":1}","endpoints":"OpenAI"} com_config_specs_preview_hidden{"count":1}', + ); + expect(previewText(localize, { items: [], mode: 'enforce', menuOn: true, added: [] })).toBe( + 'com_config_specs_preview_none', + ); + expect(previewText(localize, { items, mode: 'enforce', menuOn: true, added: [] })).toMatch( + /^com_config_specs_preview_enforce/, + ); + }); +}); diff --git a/src/components/configuration/sections/specs/__tests__/params.test.ts b/src/components/configuration/sections/specs/__tests__/params.test.ts new file mode 100644 index 00000000..114f0568 --- /dev/null +++ b/src/components/configuration/sections/specs/__tests__/params.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from 'vitest'; +import { configSchema } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { + PRESET_DENYLIST, + paramField, + presetParams, + promptKeyFor, + specPromptKeys, + specSearchFields, +} from '../params'; +import { extractSchemaTree } from '@/server/config'; + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const presetField = tree + .find((field) => field.key === 'modelSpecs') + ?.children?.find((field) => field.key === 'list') + ?.children?.find((field) => field.key === 'preset'); +const presetKeys = new Set((presetField?.children ?? []).map((field) => field.key)); + +function keysOf(set: t.PresetParamSet): string[] { + return set.params.map((param) => param.key); +} + +describe('presetParams', () => { + it('offers an Anthropic spec only Anthropic parameters', () => { + const anthropic = presetParams('anthropic', 'claude-sonnet-4-5', undefined, presetKeys); + expect(anthropic.settingsKey).toBe('anthropic'); + const keys = keysOf(anthropic); + expect(keys).toEqual(expect.arrayContaining(['maxOutputTokens', 'topP', 'topK', 'thinking'])); + expect(keys).not.toEqual(expect.arrayContaining(['top_p'])); + expect( + keys.filter((key) => + ['max_tokens', 'top_p', 'frequency_penalty', 'reasoning_effort'].includes(key), + ), + ).toEqual([]); + }); + + it('leaves the Model and Prompt fields to their own groups', () => { + const keys = keysOf(presetParams('openAI', 'gpt-4o', undefined, presetKeys)); + expect(keys).toEqual(expect.arrayContaining(['temperature', 'top_p', 'max_tokens', 'stop'])); + for (const key of ['modelLabel', 'promptPrefix', 'model', 'endpoint']) { + expect(keys).not.toContain(key); + } + }); + + it('resolves the model family (Bedrock) and a custom endpoint’s parameter set', () => { + expect(presetParams('bedrock', 'anthropic.claude-3', undefined, presetKeys).settingsKey).toBe( + 'bedrock-anthropic', + ); + expect(presetParams('Azure Foundry', 'gpt-5', undefined, presetKeys).settingsKey).toBe( + 'custom', + ); + const viaAnthropic = presetParams( + 'Claude Proxy', + 'claude', + { name: 'Claude Proxy', customParams: { defaultParamsEndpoint: 'anthropic' } }, + presetKeys, + ); + expect(viaAnthropic.settingsKey).toBe('anthropic'); + }); + + it('has no parameters for agents or without an endpoint', () => { + expect(presetParams('agents', '', undefined, presetKeys).params).toEqual([]); + expect(presetParams('', '', undefined, presetKeys)).toEqual({ settingsKey: null, params: [] }); + }); + + it('keeps ranges, defaults and real choices (the empty “auto” choice is the default)', () => { + const params = presetParams('openAI', 'gpt-4o', undefined, presetKeys).params; + const temperature = params.find((param) => param.key === 'temperature'); + expect(temperature).toMatchObject({ kind: 'number', min: 0, max: 2, defaultValue: 1 }); + const effort = params.find((param) => param.key === 'reasoning_effort'); + expect(effort?.kind).toBe('enum'); + expect(effort?.options).not.toContain(''); + expect(effort?.defaultValue).toBeUndefined(); + expect(params.find((param) => param.key === 'stop')).toMatchObject({ kind: 'list' }); + }); + + it('never offers internal conversation fields', () => { + for (const endpoint of ['openAI', 'anthropic', 'google', 'bedrock', 'Azure Foundry']) { + const keys = keysOf(presetParams(endpoint, 'm', undefined, presetKeys)); + expect(keys.filter((key) => PRESET_DENYLIST.has(key))).toEqual([]); + } + }); +}); + +describe('specPromptKeys', () => { + it('lists the endpoint prompt field, the greeting, assistant instructions and set prompt fields', () => { + expect(specPromptKeys({ model: 'gpt-4o' }, 'openAI')).toEqual(['promptPrefix', 'greeting']); + expect(specPromptKeys({ model: 'gpt-4o' }, 'assistants')).toEqual([ + 'promptPrefix', + 'greeting', + 'instructions', + 'additional_instructions', + ]); + expect(specPromptKeys({ model: 'gpt-4o', system: 'old' }, 'openAI')).toEqual([ + 'promptPrefix', + 'greeting', + 'system', + ]); + }); +}); + +describe('promptKeyFor', () => { + it('uses the system prompt where the parameter panel does (Bedrock Anthropic models)', () => { + expect(promptKeyFor('bedrock', 'anthropic.claude-3')).toBe('system'); + expect(promptKeyFor('openAI', 'gpt-4o')).toBe('promptPrefix'); + expect(promptKeyFor('', '')).toBe('promptPrefix'); + }); +}); + +describe('paramField', () => { + const field: t.SchemaField = { + path: 'p', + key: 'temperature', + type: 'number', + isOptional: true, + depth: 0, + }; + + it('adds the range to a number and the choices to an enum', () => { + expect( + paramField(field, { key: 'temperature', kind: 'number', min: 0, max: 2, step: 0.01 }) + .constraints, + ).toEqual({ + min: 0, + max: 2, + step: 0.01, + }); + expect( + paramField( + { ...field, type: 'string' }, + { key: 'effort', kind: 'enum', options: ['low', 'high'] }, + ).type, + ).toBe('enum(low | high)'); + expect(paramField(field, { key: 'x', kind: 'text' })).toBe(field); + }); +}); + +describe('specSearchFields', () => { + const specFields = + tree.find((field) => field.key === 'modelSpecs')?.children?.find((f) => f.key === 'list') + ?.children ?? []; + + function presetKeysFor(spec: t.ConfigValue, root: t.ConfigValue = null): string[] { + const preset = specSearchFields(specFields, spec, root).find((field) => field.key === 'preset'); + return (preset?.children ?? []).map((field) => field.key); + } + + it('indexes only the parameters the spec’s endpoint offers, plus what it sets', () => { + const openAI = presetKeysFor({ preset: { endpoint: 'openAI', model: 'gpt-4o', topK: 3 } }); + expect(openAI).toEqual(expect.arrayContaining(['endpoint', 'model', 'temperature', 'topK'])); + expect(openAI).not.toContain('thinkingBudget'); + expect(openAI).not.toContain('archivedAt'); + }); + + it('indexes only the prompt fields the editor shows for the endpoint, plus set ones', () => { + const openAI = presetKeysFor({ preset: { endpoint: 'openAI', model: 'gpt-4o' } }); + expect(openAI).toEqual(expect.arrayContaining(['promptPrefix', 'greeting'])); + expect(openAI).not.toContain('system'); + expect(openAI).not.toContain('instructions'); + const bedrock = presetKeysFor({ preset: { endpoint: 'bedrock', model: 'anthropic.claude-3' } }); + expect(bedrock).toContain('system'); + expect(bedrock).not.toContain('promptPrefix'); + expect(presetKeysFor({ preset: { endpoint: 'assistants', model: 'gpt-4o' } })).toEqual( + expect.arrayContaining(['instructions', 'additional_instructions']), + ); + expect( + presetKeysFor({ preset: { endpoint: 'openAI', model: 'gpt-4o', system: 'stale' } }), + ).toContain('system'); + }); + + it('indexes only the model fields the editor shows for the endpoint, plus set ones', () => { + const openAI = presetKeysFor({ preset: { endpoint: 'openAI', model: 'gpt-4o' } }); + expect(openAI).toEqual(expect.arrayContaining(['endpoint', 'model', 'modelLabel'])); + expect(openAI).not.toContain('agent_id'); + expect(openAI).not.toContain('assistant_id'); + const agents = presetKeysFor({ preset: { endpoint: 'agents', agent_id: 'agent_1' } }); + expect(agents).toContain('agent_id'); + expect(agents).not.toContain('model'); + expect(presetKeysFor({ preset: { agent_id: 'agent_1' } })).not.toContain('model'); + expect(presetKeysFor({ preset: { endpoint: 'azureAssistants', model: 'm' } })).toContain( + 'assistant_id', + ); + expect( + presetKeysFor({ preset: { endpoint: 'openAI', model: 'm', agent_id: 'stale' } }), + ).toContain('agent_id'); + }); + + it('follows a custom endpoint’s parameter set', () => { + const root = { + endpoints: { + custom: [{ name: 'Proxy', customParams: { defaultParamsEndpoint: 'anthropic' } }], + }, + }; + expect(presetKeysFor({ preset: { endpoint: 'Proxy', model: 'x' } }, root)).toContain( + 'thinkingBudget', + ); + }); +}); diff --git a/src/components/configuration/sections/specs/__tests__/validation.test.ts b/src/components/configuration/sections/specs/__tests__/validation.test.ts new file mode 100644 index 00000000..2457dfea --- /dev/null +++ b/src/components/configuration/sections/specs/__tests__/validation.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import type * as t from '@/types'; +import { specListIssues, validateSpecs, specEndpoint, problemsOf } from '../validation'; +import { getKeyedCollection } from '@/utils'; + +const known = new Set(['openAI', 'anthropic', 'agents', 'Azure Foundry']); + +function spec(name: string, extra: t.ConfigRecord = {}): t.ConfigRecord { + return { + name, + label: name.toUpperCase(), + preset: { endpoint: 'openAI', model: 'gpt-4o' }, + ...extra, + }; +} + +function codes(problems: t.SpecProblem[]): string[] { + return problems.map((problem) => `${problem.index}:${problem.field}:${problem.code}`); +} + +describe('validateSpecs', () => { + it('accepts a complete list', () => { + expect(validateSpecs([spec('a'), spec('b')], { knownEndpoints: known })).toEqual([]); + }); + + it('requires a name, label, endpoint and model on every spec', () => { + const problems = validateSpecs([{ preset: {} }, { name: ' ', label: 'X' }], { + knownEndpoints: known, + }); + expect(codes(problems)).toEqual([ + '0:name:required', + '0:label:required', + '0:preset.endpoint:required', + '0:preset.model:required', + '1:name:required', + '1:preset.endpoint:required', + '1:preset.model:required', + ]); + }); + + it('asks an agent spec for its agent, not a model, and infers the agents endpoint', () => { + const agent = { name: 'a', label: 'A', preset: { agent_id: 'agent_123' } }; + expect(specEndpoint(agent)).toBe('agents'); + expect(validateSpecs([agent], { knownEndpoints: known })).toEqual([]); + const missing = { name: 'b', label: 'B', preset: { endpoint: 'agents' } }; + expect(codes(validateSpecs([missing], { knownEndpoints: known }))).toEqual([ + '0:preset.agent_id:required', + ]); + }); + + it('flags every spec after the first that repeats a name, a default or a soft default', () => { + const items = [ + spec('a', { default: true, softDefault: true }), + spec('a', { default: true }), + spec('c', { softDefault: true }), + ]; + const problems = validateSpecs(items, { knownEndpoints: known }); + expect(codes(problems)).toEqual([ + '1:name:spec_duplicate_name', + '1:default:spec_multiple_default', + '2:softDefault:spec_multiple_soft_default', + ]); + expect(problems[0].params).toEqual({ name: 'A', position: 1 }); + }); + + it('warns, without blocking, about an endpoint the configuration does not define', () => { + const items = [spec('a', { preset: { endpoint: 'Missing', model: 'm' } })]; + const problems = validateSpecs(items, { knownEndpoints: known }); + expect(problems).toEqual([ + { + index: 0, + field: 'preset.endpoint', + code: 'spec_unknown_endpoint', + level: 'warning', + params: { endpoint: 'Missing' }, + }, + ]); + expect(validateSpecs(items, { knownEndpoints: new Set() })).toEqual([]); + }); + + it('lists a spec’s errors before its warnings', () => { + const items = [{ name: 'a', preset: { endpoint: 'Missing', model: 'm' } }]; + const problems = problemsOf(validateSpecs(items, { knownEndpoints: known }), 0); + expect(problems.map((problem) => problem.level)).toEqual(['error', 'warning']); + }); +}); + +describe('specListIssues', () => { + const collection = getKeyedCollection('modelSpecs.list')!; + + function changeSet(items: t.ConfigValue[]): t.ConfigChangeSet { + return { + ops: [], + issues: [], + payload: { touched: [], saves: [], resets: [], skipped: [] }, + collections: [{ collection, items, edit: { baseline: [], origins: [] } }], + }; + } + + it('blocks Save on spec errors at their pending position, named by a unique spec name', () => { + const issues = specListIssues( + changeSet([spec('a'), spec('b', { label: '', default: true }), spec('c', { default: true })]), + ); + expect(issues).toEqual([ + expect.objectContaining({ + path: 'modelSpecs.list.1.label', + code: 'required', + entryIdentity: 'b', + entryPath: 'label', + }), + expect.objectContaining({ + path: 'modelSpecs.list.2.default', + code: 'spec_multiple_default', + params: { name: 'b', position: 2 }, + }), + ]); + }); + + it('blocks a blank name and names that differ only by surrounding spaces', () => { + const issues = specListIssues( + changeSet([spec('a'), spec(' ', { label: 'Blank' }), spec('b'), spec(' b ')]), + ); + expect(issues.map((issue) => [issue.path, issue.code])).toEqual([ + ['modelSpecs.list.1.name', 'required'], + ['modelSpecs.list.3.name', 'spec_duplicate_name'], + ]); + }); + + it('leaves names to the pipeline and ignores warnings and untouched lists', () => { + expect( + specListIssues(changeSet([{ label: 'X', preset: { endpoint: 'x', model: 'm' } }])), + ).toEqual([]); + expect(specListIssues(changeSet([spec('a'), spec('a')]))).toEqual([]); + expect(specListIssues({ ...changeSet([]), collections: [] })).toEqual([]); + }); +}); diff --git a/src/components/configuration/sections/specs/context.ts b/src/components/configuration/sections/specs/context.ts new file mode 100644 index 00000000..3bd9d265 --- /dev/null +++ b/src/components/configuration/sections/specs/context.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; +import type * as t from '@/types'; + +/** The spec list an editor or summary belongs to (its items, problems, options and actions). */ +export const SpecListContext = createContext(null); + +export function useSpecList(): t.SpecListContextValue { + const value = useContext(SpecListContext); + if (!value) throw new Error('useSpecList must be used inside a spec list'); + return value; +} + +/** Position of a spec from its data path (`modelSpecs.list.3` → 3). */ +export function specIndexOf(path: string): number { + return Number(path.slice(path.lastIndexOf('.') + 1)); +} diff --git a/src/components/configuration/sections/specs/groups.ts b/src/components/configuration/sections/specs/groups.ts new file mode 100644 index 00000000..d9e47bca --- /dev/null +++ b/src/components/configuration/sections/specs/groups.ts @@ -0,0 +1,19 @@ +import type * as t from '@/types'; + +/** Endpoint choices grouped: built-in providers, then custom endpoints. */ +export function endpointGroups( + options: t.SpecOptions, + localize: t.LocalizeFn, +): t.SpecOptionGroup[] { + const builtIn = options.endpoints.filter((option) => !options.customEndpoints.has(option.value)); + const custom = options.endpoints.filter((option) => options.customEndpoints.has(option.value)); + return [ + { label: localize('com_config_specs_endpoints_builtin'), options: builtIn }, + { label: localize('com_config_specs_endpoints_custom'), options: custom }, + ]; +} + +/** One group of MCP server choices. */ +export function mcpGroups(options: t.SpecOptions, localize: t.LocalizeFn): t.SpecOptionGroup[] { + return [{ label: localize('com_config_specs_mcp_servers'), options: options.mcpServers }]; +} diff --git a/src/components/configuration/sections/specs/index.ts b/src/components/configuration/sections/specs/index.ts new file mode 100644 index 00000000..3dda706d --- /dev/null +++ b/src/components/configuration/sections/specs/index.ts @@ -0,0 +1,7 @@ +/** + * Model specs tab (WS11): how users pick models, the spec list (sortable, + * list order = menu order) and a purpose-built spec editor with semantic + * validation and endpoint-aware model parameters. + */ +export { ModelSpecsRenderer } from './ModelSpecsRenderer'; +export { specListIssues } from './validation'; diff --git a/src/components/configuration/sections/specs/options.ts b/src/components/configuration/sections/specs/options.ts new file mode 100644 index 00000000..025ca2d9 --- /dev/null +++ b/src/components/configuration/sections/specs/options.ts @@ -0,0 +1,124 @@ +import { useContext, useMemo } from 'react'; +import type * as t from '@/types'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { isConfigRecord, getValueAtPath } from '@/utils'; +import { useLocalize, useOptionSources } from '@/hooks'; +import { BUILT_IN_ENDPOINTS, specEndpoint } from './validation'; +import { labelForPath } from '../../meta'; + +function stringsOf(value: t.ConfigValue): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); +} + +/** Options with duplicate values dropped (the first label wins). */ +export function uniqueOptions(options: t.SelectOption[]): t.SelectOption[] { + const seen = new Set(); + return options.filter((option) => { + if (seen.has(option.value)) return false; + seen.add(option.value); + return true; + }); +} + +function customEndpointsOf(root: t.ConfigValue): Map { + const custom = getValueAtPath(root, 'endpoints.custom'); + const byName = new Map(); + for (const entry of Array.isArray(custom) ? custom : []) { + if (isConfigRecord(entry) && typeof entry.name === 'string' && entry.name.trim() !== '') { + byName.set(entry.name, entry); + } + } + return byName; +} + +/** + * Endpoint choices for a spec: the built-in providers, then the custom + * endpoints the configuration defines, then any WS9 option source adds. + */ +export function endpointOptions( + customNames: Iterable, + localize: t.LocalizeFn, + extra: t.SelectOption[] = [], +): t.SelectOption[] { + return uniqueOptions([ + ...BUILT_IN_ENDPOINTS.map((name) => ({ + value: name, + label: labelForPath(`endpoints.${name}`, localize), + })), + ...[...customNames].map((name) => ({ value: name, label: name })), + ...extra, + ]); +} + +/** Models to suggest for an endpoint: a custom endpoint's model list, then models other specs use with it. */ +export function modelSuggestions( + endpoint: string, + custom: t.ConfigRecord | undefined, + items: t.ConfigValue[], +): t.SelectOption[] { + const listed = isConfigRecord(custom?.models) ? stringsOf(custom.models.default) : []; + const used = items.flatMap((spec) => { + if (!isConfigRecord(spec) || specEndpoint(spec) !== endpoint) return []; + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + return typeof preset.model === 'string' && preset.model.trim() !== '' ? [preset.model] : []; + }); + return uniqueOptions([...listed, ...used].map((model) => ({ value: model, label: model }))); +} + +/** Group names the specs use, then endpoint names (a group named after an endpoint nests under it). */ +export function groupOptions( + items: t.ConfigValue[], + endpoints: t.SelectOption[], +): t.SelectOption[] { + const groups = items.flatMap((spec) => + isConfigRecord(spec) && typeof spec.group === 'string' && spec.group.trim() !== '' + ? [{ value: spec.group, label: spec.group }] + : [], + ); + return uniqueOptions([...groups, ...endpoints]); +} + +/** + * Everything the spec editor offers to pick from, read from the configuration + * shown on the page (pending edits included) and merged with WS9's + * `useOptionSources` where it provides more. + */ +export function useSpecOptions(items: t.ConfigValue[]): t.SpecOptions { + const localize = useLocalize(); + const root = useContext(ConfigValuesContext)?.configValues ?? null; + const endpointSource = useOptionSources('endpoints'); + const mcpSource = useOptionSources('mcpServers'); + const customEndpoints = useMemo(() => customEndpointsOf(root), [root]); + const mcpRecord = getValueAtPath(root, 'mcpServers'); + + const endpoints = useMemo( + () => endpointOptions(customEndpoints.keys(), localize, endpointSource.options), + [customEndpoints, localize, endpointSource.options], + ); + const mcpServers = useMemo( + () => + uniqueOptions([ + ...Object.keys(isConfigRecord(mcpRecord) ? mcpRecord : {}).map((name) => ({ + value: name, + label: name, + })), + ...mcpSource.options, + ]), + [mcpRecord, mcpSource.options], + ); + + return useMemo( + () => ({ + endpoints, + knownEndpoints: new Set(endpoints.map((option) => option.value)), + customEndpoints: new Set(customEndpoints.keys()), + mcpServers, + groups: groupOptions(items, endpoints), + modelsFor: (endpoint: string) => + modelSuggestions(endpoint, customEndpoints.get(endpoint), items), + customEndpoint: (name: string) => customEndpoints.get(name), + }), + [endpoints, customEndpoints, mcpServers, items], + ); +} diff --git a/src/components/configuration/sections/specs/params.ts b/src/components/configuration/sections/specs/params.ts new file mode 100644 index 00000000..f42ad834 --- /dev/null +++ b/src/components/configuration/sections/specs/params.ts @@ -0,0 +1,246 @@ +import { EModelEndpoint, paramSettings, getSettingsKeys } from 'librechat-data-provider'; +import type { SettingDefinition } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { isConfigRecord, getValueAtPath } from '@/utils'; +import { specEndpoint } from './validation'; + +/** + * Preset keys that are conversation state or internal plumbing, never + * something a spec should set (or that clash with a spec-level field of the + * same name: the spec's own `iconURL` and `artifacts` are the ones used). + */ +export const PRESET_DENYLIST: ReadonlySet = new Set([ + 'archivedAt', + 'pinned', + 'isShared', + 'isTemporary', + 'tools', + 'subagentThread', + 'codeWorkspaces', + 'endpointType', + 'stream', + 'iconURL', + 'artifacts', + 'userLabel', +]); + +/** Preset keys the editor shows in its own groups (Model, Prompt), not among the parameters. */ +export const MODEL_KEYS: readonly string[] = [ + 'endpoint', + 'model', + 'modelLabel', + 'agent_id', + 'assistant_id', +]; +export const PROMPT_KEYS: readonly string[] = [ + 'promptPrefix', + 'system', + 'instructions', + 'additional_instructions', + 'greeting', +]; +const GROUPED_KEYS: ReadonlySet = new Set([...MODEL_KEYS, ...PROMPT_KEYS]); +const GREETING_KEY = 'greeting'; +/** Prompt fields only the assistants endpoints use (shown elsewhere only when set). */ +const ASSISTANT_PROMPT_KEYS: ReadonlySet = new Set([ + 'instructions', + 'additional_instructions', +]); + +const BUILT_IN: ReadonlySet = new Set(Object.values(EModelEndpoint)); +export const ASSISTANT_ENDPOINTS: ReadonlySet = new Set([ + EModelEndpoint.assistants, + EModelEndpoint.azureAssistants, +]); + +function paramKind(setting: SettingDefinition): t.PresetParamKind | null { + if (setting.type === 'boolean') return 'boolean'; + if (setting.type === 'number') return 'number'; + if (setting.type === 'array') return 'list'; + if (setting.type === 'enum') return 'enum'; + if (setting.type === 'string') return setting.component === 'textarea' ? 'textarea' : 'text'; + return null; +} + +function toParam(setting: SettingDefinition): t.PresetParam | null { + const kind = paramKind(setting); + if (!kind) return null; + const param: t.PresetParam = { key: setting.key, kind }; + if (setting.range) { + param.min = setting.range.min; + param.max = setting.range.max; + if (setting.range.step !== undefined) param.step = setting.range.step; + } + if (kind === 'enum' && setting.options) { + param.options = setting.options.filter((option) => option !== ''); + } + const fallback = setting.default; + const isEmptyDefault = fallback === '' || (Array.isArray(fallback) && fallback.length === 0); + if (fallback !== undefined && !isEmptyDefault) param.defaultValue = fallback; + return param; +} + +/** + * The endpoint type LibreChat resolves parameters by: a built-in provider is + * its own type; any other name is a custom endpoint, whose parameters follow + * its `customParams.defaultParamsEndpoint` when set. + */ +function settingsEndpoint(endpoint: string, custom: t.ConfigRecord | undefined): string { + if (BUILT_IN.has(endpoint)) return endpoint; + const customParams = isConfigRecord(custom?.customParams) ? custom.customParams : undefined; + const defaultParams = customParams?.defaultParamsEndpoint; + return typeof defaultParams === 'string' && defaultParams !== '' + ? defaultParams + : EModelEndpoint.custom; +} + +/** + * The model parameters a spec's preset can set for its endpoint and model, + * from LibreChat's own parameter panel settings (`paramSettings`): the model + * family key first (`bedrock-anthropic`), else the endpoint's. The Model and + * Prompt fields are left out (the editor shows them in their own groups), and + * so are parameters the preset schema can't store. + */ +export function presetParams( + endpoint: string, + model: string, + custom: t.ConfigRecord | undefined, + presetKeys: ReadonlySet, +): t.PresetParamSet { + if (!endpoint) return { settingsKey: null, params: [] }; + const type = settingsEndpoint(endpoint, custom); + const [combinedKey, endpointKey] = getSettingsKeys(type, model); + const settingsKey = paramSettings[combinedKey] ? combinedKey : endpointKey; + const settings = paramSettings[settingsKey]; + if (!settings) return { settingsKey: null, params: [] }; + const params = settings.flatMap((setting) => { + if (GROUPED_KEYS.has(setting.key) || !presetKeys.has(setting.key)) return []; + const param = toParam(setting); + return param ? [param] : []; + }); + return { settingsKey, params }; +} + +/** The prompt field an endpoint's parameter panel uses (`system` for Bedrock's Anthropic models). */ +export function promptKeyFor(endpoint: string, model: string, custom?: t.ConfigRecord): string { + if (!endpoint) return 'promptPrefix'; + const type = settingsEndpoint(endpoint, custom); + const [combinedKey, endpointKey] = getSettingsKeys(type, model); + const settings = paramSettings[combinedKey] ?? paramSettings[endpointKey] ?? []; + return settings.some((setting) => setting.key === 'system') ? 'system' : 'promptPrefix'; +} + +function isSet(value: t.ConfigValue): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** + * The Model group's fields for a spec's endpoint, in order: the endpoint, the + * agent (an agents spec) or else the model, the assistant on an assistants + * endpoint, and the model label. A field the endpoint doesn't use is listed + * only while the preset still sets it, so a stale value stays removable. + */ +export function specModelKeys(preset: t.ConfigRecord, endpoint: string): string[] { + const isAgent = endpoint === EModelEndpoint.agents; + const [primary, secondary] = isAgent ? ['agent_id', 'model'] : ['model', 'agent_id']; + const usesAssistant = ASSISTANT_ENDPOINTS.has(endpoint) || isSet(preset.assistant_id); + return [ + 'endpoint', + primary, + ...(isSet(preset[secondary]) ? [secondary] : []), + ...(usesAssistant ? ['assistant_id'] : []), + 'modelLabel', + ]; +} + +/** The preset keys the Parameters group lists: the endpoint's parameters, then any other key set. */ +export function paramGroupKeys(params: t.PresetParam[], preset: t.ConfigRecord): string[] { + const keys = new Set(params.map((param) => param.key)); + for (const key of Object.keys(preset)) { + if (!GROUPED_KEYS.has(key) && preset[key] !== undefined) keys.add(key); + } + return [...keys]; +} + +/** + * The prompt fields a spec's editor shows, in order: the prompt field of its + * endpoint's parameter panel (`promptKeyFor`), the greeting, the assistants' + * instructions on an assistants endpoint, and any other prompt field the + * preset already sets (so a stale value stays visible and removable). + */ +export function specPromptKeys( + preset: t.ConfigRecord, + endpoint: string, + custom?: t.ConfigRecord, +): string[] { + const model = typeof preset.model === 'string' ? preset.model : ''; + const promptKey = promptKeyFor(endpoint, model, custom); + const isAssistant = ASSISTANT_ENDPOINTS.has(endpoint); + return [ + promptKey, + GREETING_KEY, + ...PROMPT_KEYS.filter( + (key) => + key !== promptKey && + key !== GREETING_KEY && + ((isAssistant && ASSISTANT_PROMPT_KEYS.has(key)) || isSet(preset[key])), + ), + ]; +} + +/** A schema field with a parameter's range and choices, so the shared control enforces them. */ +export function paramField(field: t.SchemaField, param: t.PresetParam): t.SchemaField { + if (param.kind === 'enum' && param.options?.length) { + return { ...field, type: `enum(${param.options.join(' | ')})` }; + } + if (param.kind !== 'number' || (param.min === undefined && param.max === undefined)) { + return field; + } + return { + ...field, + constraints: { + ...field.constraints, + ...(param.min !== undefined && { min: param.min }), + ...(param.max !== undefined && { max: param.max }), + ...(param.step !== undefined && { step: param.step }), + }, + }; +} + +/** A custom endpoint's settings by name, from the whole configuration. */ +function customEndpointOf(root: t.ConfigValue, name: string): t.ConfigRecord | undefined { + const custom = getValueAtPath(root, 'endpoints.custom'); + const entry = Array.isArray(custom) + ? custom.find((item) => isConfigRecord(item) && item.name === name) + : undefined; + return isConfigRecord(entry) ? entry : undefined; +} + +/** + * A spec's fields as its editor shows them, for search: the preset narrowed + * to the Model fields of its endpoint (`specModelKeys`), the Prompt fields + * the editor shows for it (`specPromptKeys`), its endpoint's parameters, and + * whatever else the preset sets (so neither "Thinking budget", "System + * prompt" nor "Agent ID" is found on an OpenAI spec). + */ +export function specSearchFields( + fields: t.SchemaField[], + spec: t.ConfigValue, + root: t.ConfigValue, +): t.SchemaField[] { + const preset = isConfigRecord(spec) && isConfigRecord(spec.preset) ? spec.preset : {}; + return fields.map((field) => { + if (field.key !== 'preset' || !field.children) return field; + const presetKeys = new Set(field.children.map((child) => child.key)); + const endpoint = specEndpoint(spec); + const model = typeof preset.model === 'string' ? preset.model : ''; + const custom = customEndpointOf(root, endpoint); + const shown = new Set([ + ...specModelKeys(preset, endpoint), + ...specPromptKeys(preset, endpoint, custom), + ...presetParams(endpoint, model, custom, presetKeys).params.map((param) => param.key), + ...Object.keys(preset), + ]); + return { ...field, children: field.children.filter((child) => shown.has(child.key)) }; + }); +} diff --git a/src/components/configuration/sections/specs/validation.ts b/src/components/configuration/sections/specs/validation.ts new file mode 100644 index 00000000..f125ea5d --- /dev/null +++ b/src/components/configuration/sections/specs/validation.ts @@ -0,0 +1,211 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { getEntryKey, isConfigRecord, getKeyedCollection } from '@/utils'; + +export const SPEC_LIST_PATH = 'modelSpecs.list'; + +/** Built-in providers a spec can name (`custom` is a type, not an endpoint). */ +export const BUILT_IN_ENDPOINTS: readonly string[] = Object.values(EModelEndpoint).filter( + (endpoint) => endpoint !== EModelEndpoint.custom, +); + +/** English fallback of each issue code (the page shows `com_config_issue_`). */ +const ISSUE_TEXT: Record = { + required: 'is required', + spec_multiple_default: 'Only one spec can be the default', + spec_multiple_soft_default: 'Only one spec can be the soft default', +}; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function presetOf(spec: t.ConfigRecord): t.ConfigRecord { + return isConfigRecord(spec.preset) ? spec.preset : {}; +} + +/** + * The endpoint a spec uses, as LibreChat resolves it: the preset's endpoint, + * or the agents endpoint when the preset names only an agent. + */ +export function specEndpoint(spec: t.ConfigValue): string { + if (!isConfigRecord(spec)) return ''; + const preset = presetOf(spec); + const endpoint = textOf(preset.endpoint); + if (endpoint) return endpoint; + return textOf(preset.agent_id) ? EModelEndpoint.agents : ''; +} + +/** Whether a spec runs an agent (the agents endpoint picks its model from the agent). */ +export function isAgentSpec(spec: t.ConfigValue): boolean { + return specEndpoint(spec) === EModelEndpoint.agents; +} + +function requiredProblems(spec: t.ConfigRecord, index: number): t.SpecProblem[] { + const preset = presetOf(spec); + const problems: t.SpecProblem[] = []; + const required = (field: string) => + problems.push({ index, field, code: 'required', level: 'error' }); + if (!textOf(spec.name)) required('name'); + if (!textOf(spec.label)) required('label'); + const endpoint = specEndpoint(spec); + if (!endpoint) required('preset.endpoint'); + if (endpoint === EModelEndpoint.agents) { + if (!textOf(preset.agent_id)) required('preset.agent_id'); + } else if (!textOf(preset.model)) { + required('preset.model'); + } + return problems; +} + +/** One problem for each spec after the first that repeats a value the list allows once. */ +function repeatProblems( + items: t.ConfigValue[], + valueOf: (spec: t.ConfigRecord) => string | null, + field: string, + code: string, +): t.SpecProblem[] { + const first = new Map(); + const problems: t.SpecProblem[] = []; + items.forEach((spec, index) => { + if (!isConfigRecord(spec)) return; + const value = valueOf(spec); + if (value === null) return; + const owner = first.get(value); + if (owner === undefined) { + first.set(value, index); + return; + } + const ownerSpec = items[owner]; + const name = isConfigRecord(ownerSpec) ? textOf(ownerSpec.label) || textOf(ownerSpec.name) : ''; + problems.push({ index, field, code, level: 'error', params: { name, position: owner + 1 } }); + }); + return problems; +} + +function unknownEndpointProblem( + spec: t.ConfigRecord, + index: number, + known: ReadonlySet, +): t.SpecProblem[] { + const endpoint = textOf(presetOf(spec).endpoint); + if (!endpoint || known.size === 0 || known.has(endpoint)) return []; + return [ + { + index, + field: 'preset.endpoint', + code: 'spec_unknown_endpoint', + level: 'warning', + params: { endpoint }, + }, + ]; +} + +/** + * Semantic checks of a spec list (MS-2) that the schema can't express: a + * name, label, endpoint and model (or agent) on every spec, unique names (the + * id conversations and `?spec=` links use), at most one default and one soft + * default, and a warning for an endpoint the configuration doesn't define. + */ +export function validateSpecs( + items: t.ConfigValue[], + ctx: t.SpecValidationContext, +): t.SpecProblem[] { + const perSpec = items.flatMap((spec, index) => + isConfigRecord(spec) + ? [ + ...requiredProblems(spec, index), + ...unknownEndpointProblem(spec, index, ctx.knownEndpoints), + ] + : [], + ); + return [ + ...perSpec, + ...repeatProblems(items, (spec) => textOf(spec.name) || null, 'name', 'spec_duplicate_name'), + ...repeatProblems( + items, + (spec) => (spec.default === true ? 'default' : null), + 'default', + 'spec_multiple_default', + ), + ...repeatProblems( + items, + (spec) => (spec.softDefault === true ? 'soft' : null), + 'softDefault', + 'spec_multiple_soft_default', + ), + ]; +} + +/** Problems of one spec, errors first. */ +export function problemsOf(problems: t.SpecProblem[], index: number): t.SpecProblem[] { + return problems + .filter((problem) => problem.index === index) + .sort((a, b) => Number(a.level === 'warning') - Number(b.level === 'warning')); +} + +/** The first problem of a spec field. */ +export function problemAt( + problems: t.SpecProblem[], + index: number, + field: string, +): t.SpecProblem | undefined { + return problemsOf(problems, index).find((problem) => problem.field === field); +} + +/** + * Whether the save pipeline reports a name problem itself. It keys entries by + * the name as written: it flags an entry without one and a new or renamed + * entry taking an exact name already used, but not a blank (whitespace-only) + * name or names that only differ by surrounding spaces, which + * `validateSpecs` compares trimmed. + */ +function isPipelineNameProblem( + problem: t.SpecProblem, + rawName: string | undefined, + firstIndex: ReadonlyMap, +): boolean { + if (problem.field !== 'name') return false; + if (problem.code === 'required') return rawName === undefined; + if (problem.code !== 'spec_duplicate_name' || rawName === undefined) return false; + return (firstIndex.get(rawName) ?? problem.index) < problem.index; +} + +/** + * Save-blocking issues of a pending spec list, for the save pipeline (WS1): + * the errors of `validateSpecs`, at the spec's position in the pending list. + * Name problems the pipeline reports itself are left out, and an entry is + * named by its name when that is unique. + */ +export function specListIssues(changeSet: t.ConfigChangeSet): t.ConfigIssue[] { + const pending = changeSet.collections.find( + (collection) => collection.collection.path === SPEC_LIST_PATH, + ); + const collection = getKeyedCollection(SPEC_LIST_PATH); + if (!pending || !collection) return []; + const { items } = pending; + const names = new Map(); + const rawNames = items.map((item) => getEntryKey(item, collection.key)); + const firstIndex = new Map(); + items.forEach((item, index) => { + const name = isConfigRecord(item) ? textOf(item.name) : ''; + if (name) names.set(name, (names.get(name) ?? 0) + 1); + const raw = rawNames[index]; + if (raw !== undefined && !firstIndex.has(raw)) firstIndex.set(raw, index); + }); + return validateSpecs(items, { knownEndpoints: new Set() }) + .filter((problem) => problem.level === 'error') + .filter((problem) => !isPipelineNameProblem(problem, rawNames[problem.index], firstIndex)) + .map((problem): t.ConfigIssue => { + const spec = items[problem.index]; + const name = isConfigRecord(spec) ? textOf(spec.name) : ''; + const unique = name !== '' && names.get(name) === 1; + return { + path: `${SPEC_LIST_PATH}.${problem.index}.${problem.field}`, + code: problem.code, + message: ISSUE_TEXT[problem.code] ?? problem.code, + params: problem.params, + ...(unique && { entryIdentity: name, entryPath: problem.field }), + }; + }); +} diff --git a/src/hooks/config/draft.test.tsx b/src/hooks/config/draft.test.tsx index 3850bf61..c0afcf2b 100644 --- a/src/hooks/config/draft.test.tsx +++ b/src/hooks/config/draft.test.tsx @@ -19,6 +19,32 @@ describe('reconcileKeys', () => { ).toEqual(['x', 'y']); }); + it('moves keys with their items when a list is reordered', () => { + const a = { name: 'a' }; + const b = { name: 'b' }; + const c = { name: 'c' }; + expect(reconcileKeys([a, b, c], ['x', 'y', 'z'], [b, a, c], byName, createKey)).toEqual([ + 'y', + 'x', + 'z', + ]); + expect(reconcileKeys([a, b, c], ['x', 'y', 'z'], [c, a, b], byName, createKey)).toEqual([ + 'z', + 'x', + 'y', + ]); + const reloaded = [{ name: 'b' }, { name: 'a' }, c]; + expect(reconcileKeys([a, b, c], ['x', 'y', 'z'], reloaded, byName, createKey)).toEqual([ + 'x', + 'y', + 'z', + ]); + expect(reconcileKeys(['a', 'b'], ['x', 'y'], ['b', 'c'], () => null, createKey)).toEqual([ + 'x', + 'y', + ]); + }); + it('keeps the remaining keys when an item is removed, and makes a new key for an append', () => { const a = { name: 'a' }; const b = { name: 'b' }; diff --git a/src/hooks/config/keys.ts b/src/hooks/config/keys.ts index 25954ec6..4de1d1a1 100644 --- a/src/hooks/config/keys.ts +++ b/src/hooks/config/keys.ts @@ -1,11 +1,39 @@ import { useRef } from 'react'; import type * as t from '@/types'; +/** + * Keys for a list of unchanged length: by position (in-place edits and + * renames make new items), unless the changed positions only hold previous + * items moved there (a reorder), which take their keys with them. + */ +function keysOfSameLength( + prev: readonly T[], + prevKeys: readonly string[], + next: readonly T[], +): string[] { + const changed = next.flatMap((item, i) => (Object.is(item, prev[i]) ? [] : [i])); + if (changed.length < 2) return [...prevKeys]; + const slots = new Map(); + for (const j of changed) { + const list = slots.get(prev[j]); + if (list) list.push(j); + else slots.set(prev[j], [j]); + } + const keys = [...prevKeys]; + for (const i of changed) { + const from = slots.get(next[i])?.shift(); + if (from === undefined) return [...prevKeys]; + keys[i] = prevKeys[from]; + } + return keys; +} + /** * Carries React keys from `prev` to `next` items: same length keeps keys by - * position (in-place edits, renames); otherwise an item reuses the key of an - * unused previous item that is the same reference or has the same identity, - * and anything else gets a fresh key. Removing, appending or reloading a list + * position (in-place edits, renames) or, for a reorder, moves them with + * their items; otherwise an item reuses the key of an unused previous item + * that is the same reference or has the same identity, and anything else + * gets a fresh key. Removing, appending, moving or reloading entries * therefore never remounts (or collapses) the entries that stayed. */ export function reconcileKeys( @@ -15,7 +43,9 @@ export function reconcileKeys( identity: (item: T) => string | null, createKey: () => string, ): string[] { - if (prev.length === next.length && prevKeys.length === prev.length) return [...prevKeys]; + if (prev.length === next.length && prevKeys.length === prev.length) { + return keysOfSameLength(prev, prevKeys, next); + } const used = new Set(); const byIdentity = new Map(); for (let i = 0; i < prev.length; i++) { diff --git a/src/hooks/config/save.ts b/src/hooks/config/save.ts index 60b40b64..bba93112 100644 --- a/src/hooks/config/save.ts +++ b/src/hooks/config/save.ts @@ -11,6 +11,7 @@ import { mergedBackChanges, } from '@/utils'; import { validateMcpCrossField } from '@/components/configuration/sections/mcp/validation'; +import { specListIssues } from '@/components/configuration/sections/specs/validation'; import { buildConfigChanges, normalizeEditValue } from '@/components/configuration/edits'; import { isResetSent, summarizeChanges } from '@/components/configuration/summary'; import { describeSkippedChanges } from '@/components/configuration/review/group'; @@ -236,8 +237,8 @@ export function useConfigSave({ const plan = useMemo(() => planConfigChanges(changeSet.ops, snapshot), [changeSet, snapshot]); const clientIssues = useMemo( - () => [...changeSet.issues, ...plan.issues], - [changeSet.issues, plan.issues], + () => [...changeSet.issues, ...plan.issues, ...specListIssues(changeSet)], + [changeSet, plan.issues], ); const issues = useMemo(() => [...clientIssues, ...serverIssues], [clientIssues, serverIssues]); diff --git a/src/hooks/config/sections.ts b/src/hooks/config/sections.ts index b029af00..17bc7dc9 100644 --- a/src/hooks/config/sections.ts +++ b/src/hooks/config/sections.ts @@ -18,10 +18,18 @@ const PERMISSIONS_BANNER_LINK: t.InfoBannerLink = { labelKey: 'com_config_ui_permissions_link', }; -/** Display name of a list entry: its name or label, else "Entry N". */ -function entryLabel(entry: t.ConfigValue, index: number, localize: t.LocalizeFn): string { +/** A model spec is shown by its label (what the model menu shows), else its name. */ +const SPEC_LABEL_KEYS = ['label', 'name'] as const; + +/** Display name of a list entry: its name or label (label first for specs), else "Entry N". */ +function entryLabel( + entry: t.ConfigValue, + index: number, + localize: t.LocalizeFn, + keys: readonly string[] = ['name', 'label'], +): string { if (isConfigRecord(entry)) { - for (const key of ['name', 'label'] as const) { + for (const key of keys) { const value = entry[key]; if (typeof value === 'string' && value) return value; } @@ -82,7 +90,7 @@ function collectionTocItems( const list = getValueAtPath(values, 'modelSpecs.list'); return (Array.isArray(list) ? list : []).map((entry, i) => ({ id: `section-modelSpecs-list-${i}`, - label: entryLabel(entry, i, localize), + label: entryLabel(entry, i, localize, SPEC_LABEL_KEYS), dataPath: `modelSpecs.list.${i}`, path: `modelSpecs.list.${i}`, })); diff --git a/src/hooks/useActiveSection.ts b/src/hooks/useActiveSection.ts index 9d21c975..2f90e038 100644 --- a/src/hooks/useActiveSection.ts +++ b/src/hooks/useActiveSection.ts @@ -103,6 +103,15 @@ export function useActiveSection( if (top <= probeY) bestId = id; } + /** At the top (or when nothing scrolls), the first entry is the one being read. */ + if (scrollContainer.scrollTop <= 0) { + const first = [...headers].find((header) => + tocIds.has(header.getAttribute('data-section-id') ?? ''), + ); + applyActive(first?.getAttribute('data-section-id') ?? bestId); + return; + } + // Near-bottom override: when scrolled to the bottom, pick the last visible // TOC-eligible header — it may never reach the probe point. const atBottom = diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1c8dc00e..c1b66ee5 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -525,6 +525,10 @@ "com_config_issue_process_mcp_field": "can only be configured in librechat.yaml for process-backed MCP servers (command, args, env, cwd, stderr, stdio)", "com_config_issue_required": "is required", "com_config_issue_secret_required_for_rename": "Re-enter the API key to rename \"{{name}}\": its stored key can't be carried over to a new name", + "com_config_issue_spec_duplicate_name": "Spec {{position}} (“{{name}}”) already uses this name", + "com_config_issue_spec_multiple_default": "Spec {{position}} (“{{name}}”) is already the default: only one spec can be", + "com_config_issue_spec_multiple_soft_default": "Spec {{position}} (“{{name}}”) is already the soft default: only one spec can be", + "com_config_issue_spec_unknown_endpoint": "“{{endpoint}}” isn't a configured endpoint: chats with this spec fail until it exists.", "com_config_issue_too_big": "must be at most {{maximum}}", "com_config_issue_too_big_exclusive": "must be less than {{maximum}}", "com_config_issue_too_big_items": "must have at most {{maximum}} items", @@ -603,6 +607,101 @@ "com_config_section_speech_desc": "TTS and STT settings", "com_config_section_model_specs": "Model specs", "com_config_section_model_specs_desc": "Preset models users pick from the model menu", + "com_config_specs_add": "Add spec", + "com_config_specs_added": "Also show these endpoints", + "com_config_specs_added_add": "Add endpoint…", + "com_config_specs_added_desc": "Endpoints listed in the model menu next to the specs. Leave empty to list every endpoint users can use.", + "com_config_specs_added_menu_off": "The endpoint menu is off (Model select in Features › Interface), so these endpoints aren't listed.", + "com_config_specs_badge_default": "Default", + "com_config_specs_badge_default_desc": "Every new chat starts with this spec", + "com_config_specs_badge_errors_one": "{{count}} problem", + "com_config_specs_badge_errors_other": "{{count}} problems", + "com_config_specs_badge_hidden": "Hidden", + "com_config_specs_badge_hidden_desc": "Not listed in the model menu; still works when chosen by name", + "com_config_specs_badge_soft": "Soft default", + "com_config_specs_badge_soft_desc": "New chats start with this spec for users who haven't picked a model yet", + "com_config_specs_badge_warnings_one": "{{count}} warning", + "com_config_specs_badge_warnings_other": "{{count}} warnings", + "com_config_specs_chip_added": "Added {{name}}", + "com_config_specs_chip_none": "None", + "com_config_specs_chip_remove": "Remove {{name}}", + "com_config_specs_chip_removed": "Removed {{name}}", + "com_config_specs_customize": "Customize list for {{name}}", + "com_config_specs_customizing": "You're editing a copy of Base's list. When you save a change, this copy replaces Base's list for {{name}}, and later changes to Base's list won't reach them until you revert it.", + "com_config_specs_default": "Default for new chats", + "com_config_specs_default_default": "Default", + "com_config_specs_default_auto": "Default (Auto)", + "com_config_specs_default_desc": "Default: every new chat starts with this spec. Soft default: only for users who haven't picked a model yet. One spec can be each, so choosing it here takes it off any other spec.", + "com_config_specs_default_none": "No", + "com_config_specs_default_provider": "Default (provider decides)", + "com_config_specs_default_soft": "Soft default", + "com_config_specs_empty": "No model specs yet. Add one to offer a preset model in the menu.", + "com_config_specs_empty_readonly": "No model specs.", + "com_config_specs_endpoint_placeholder": "Choose an endpoint…", + "com_config_specs_endpoints_builtin": "Providers", + "com_config_specs_endpoints_custom": "Custom endpoints", + "com_config_specs_enforce_menu_on": "Users still see the endpoint menu (Model select is on in Features › Interface), but chats without a spec are refused. Turn Model select off to show only specs.", + "com_config_specs_group_advanced": "Advanced", + "com_config_specs_group_basics": "Basics", + "com_config_specs_group_capabilities": "Capabilities", + "com_config_specs_group_capabilities_desc": "Tools chats with this spec can use.", + "com_config_specs_group_defaults": "New chats", + "com_config_specs_group_menu": "Menu and visibility", + "com_config_specs_group_model": "Model", + "com_config_specs_group_model_desc": "The endpoint and model chats with this spec use.", + "com_config_specs_group_params": "Parameters", + "com_config_specs_group_params_desc": "Model settings chats with this spec start with. Unset ones use the endpoint's defaults.", + "com_config_specs_group_prompt": "Prompt", + "com_config_specs_group_subagents": "Subagents", + "com_config_specs_group_subagents_desc": "Let this spec hand work off to agents.", + "com_config_specs_icon_placeholder": "Image URL or provider name", + "com_config_specs_inherited": "These specs come from Base. A profile can't change single specs: it keeps its own copy of the whole list, which replaces Base's list for {{name}}.", + "com_config_specs_list": "Specs", + "com_config_specs_list_desc": "List order is the order of the model menu. Without a default, new chats start with the first spec when specs come first.", + "com_config_specs_mcp_add": "Add MCP server…", + "com_config_specs_mcp_servers": "MCP servers", + "com_config_specs_mode": "How users pick models", + "com_config_specs_mode_desc": "Whether chats must use a spec, start with one, or keep the user's last endpoint.", + "com_config_specs_mode_endpoints": "Endpoints first", + "com_config_specs_mode_endpoints_desc": "Specs are listed in the model menu, but new chats keep the user's last endpoint and model.", + "com_config_specs_mode_enforce": "Specs only", + "com_config_specs_mode_enforce_desc": "Every chat must use a spec; requests without one are refused.", + "com_config_specs_mode_mixed": "Specs are enforced but not put first, which LibreChat warns can behave unexpectedly: new chats don't start with a spec, yet chats without one are refused. Choose Specs only to fix it.", + "com_config_specs_mode_prioritize": "Specs first", + "com_config_specs_mode_prioritize_desc": "New chats start with a spec; users can still choose other endpoints.", + "com_config_specs_mode_revert": "Revert to librechat.yaml", + "com_config_specs_mode_revert_scope": "Revert to Base", + "com_config_specs_model_placeholder": "Model name", + "com_config_specs_new": "New spec", + "com_config_specs_not_configured": "{{name}} (not configured)", + "com_config_specs_open_model_select": "Go to Model select", + "com_config_specs_order_desc": "Not used for the menu order: the list order is. Kept for compatibility.", + "com_config_specs_params_no_endpoint": "Choose an endpoint to see its parameters.", + "com_config_specs_params_none": "{{endpoint}} has no model parameters a spec can preset.", + "com_config_specs_params_other": "Other preset fields.", + "com_config_specs_params_other_desc": "Set on this spec but not among {{endpoint}}'s parameters, so the endpoint may ignore them.", + "com_config_specs_pending_revert": "The list goes back to librechat.yaml when you save.", + "com_config_specs_pending_revert_scope": "The list goes back to Base's when you save.", + "com_config_specs_preview_added": "{{specs}}, plus {{endpoints}} in the endpoint menu.", + "com_config_specs_preview_all": "{{specs}}, plus every endpoint they can use.", + "com_config_specs_preview_enforce": "{{specs}} only. Every chat must use a spec.", + "com_config_specs_preview_hidden_one": "{{count}} more is hidden from the menu.", + "com_config_specs_preview_hidden_other": "{{count}} more are hidden from the menu.", + "com_config_specs_preview_label": "Users will see:", + "com_config_specs_preview_menu_off": "{{specs}} only. The endpoint menu is off.", + "com_config_specs_preview_none": "No specs, only the endpoint menu.", + "com_config_specs_preview_specs_one": "{{count}} spec", + "com_config_specs_preview_specs_other": "{{count}} specs", + "com_config_specs_provider_web_search": "Provider web search", + "com_config_specs_raw": "Advanced (raw)", + "com_config_specs_raw_add": "Add a preset field…", + "com_config_specs_raw_desc": "Any other preset field LibreChat accepts. Use with care: an endpoint ignores fields it doesn't support.", + "com_config_specs_raw_option": "{{label}} ({{key}})", + "com_config_specs_raw_yaml": "Preset as YAML", + "com_config_specs_rename_warning": "Renaming breaks saved conversations and ?spec= links that use “{{name}}”.", + "com_config_specs_summary_group": "in {{group}}", + "com_config_specs_this_endpoint": "this endpoint", + "com_config_specs_will_own": "Saving makes this list an admin override: later changes to the list in librechat.yaml won't apply until you revert it.", "com_config_section_mcp_servers": "MCP servers", "com_config_section_mcp_servers_desc": "Model Context Protocol servers that agents and chats can use", "com_config_section_rate_limits": "Rate limits", @@ -1029,6 +1128,49 @@ "com_config_desc_modelSpecs_list_preset": "Endpoint, model and parameters that conversations with this spec start from.", "com_config_desc_modelSpecs_list_preset_endpoint": "Endpoint the spec uses: a built-in provider or a custom endpoint's name.", "com_config_desc_modelSpecs_list_preset_model": "Model the spec uses.", + "com_config_desc_modelSpecs_list_preset_additional_instructions": "Instructions added to the assistant's own.", + "com_config_desc_modelSpecs_list_preset_agent_id": "The agent chats with this spec run. Its model and tools come from the agent.", + "com_config_desc_modelSpecs_list_preset_assistant_id": "The assistant chats with this spec use.", + "com_config_desc_modelSpecs_list_preset_disableStreaming": "Show replies only once they are complete.", + "com_config_desc_modelSpecs_list_preset_effort": "How much effort the model puts into its answer.", + "com_config_desc_modelSpecs_list_preset_fileTokenLimit": "Most tokens of attached files sent to the model.", + "com_config_desc_modelSpecs_list_preset_frequency_penalty": "Makes the model less likely to repeat the same words.", + "com_config_desc_modelSpecs_list_preset_greeting": "Text shown on the landing page when the spec has no description.", + "com_config_desc_modelSpecs_list_preset_imageDetail": "Resolution images are sent at: higher costs more tokens.", + "com_config_desc_modelSpecs_list_preset_instructions": "Instructions for the assistant, in place of its own.", + "com_config_desc_modelSpecs_list_preset_maxContextTokens": "Most tokens of the conversation sent to the model; older messages are left out.", + "com_config_desc_modelSpecs_list_preset_maxOutputTokens": "Most tokens the model can write in one reply.", + "com_config_desc_modelSpecs_list_preset_maxTokens": "Most tokens the model can write in one reply.", + "com_config_desc_modelSpecs_list_preset_max_tokens": "Most tokens the model can write in one reply.", + "com_config_desc_modelSpecs_list_preset_modelLabel": "Name the chat shows for the model, in place of its own.", + "com_config_desc_modelSpecs_list_preset_presence_penalty": "Makes the model more likely to move on to new topics.", + "com_config_desc_modelSpecs_list_preset_promptCache": "Cache the prompt with the provider to lower cost and latency.", + "com_config_desc_modelSpecs_list_preset_promptCacheTtl": "How long the provider keeps the cached prompt.", + "com_config_desc_modelSpecs_list_preset_promptPrefix": "Instructions sent to the model at the start of every chat with this spec.", + "com_config_desc_modelSpecs_list_preset_reasoning_context": "Which turns' reasoning the model keeps.", + "com_config_desc_modelSpecs_list_preset_reasoning_effort": "How much the model reasons before answering.", + "com_config_desc_modelSpecs_list_preset_reasoning_mode": "Reasoning mode, for models that offer one.", + "com_config_desc_modelSpecs_list_preset_reasoning_summary": "How much of its reasoning the model summarizes.", + "com_config_desc_modelSpecs_list_preset_region": "AWS region requests are sent to.", + "com_config_desc_modelSpecs_list_preset_resendFiles": "Send files attached earlier in the chat again with each message.", + "com_config_desc_modelSpecs_list_preset_stop": "Text that ends the reply when the model writes it. Spaces and line breaks count.", + "com_config_desc_modelSpecs_list_preset_system": "System prompt sent to the model at the start of every chat with this spec.", + "com_config_desc_modelSpecs_list_preset_temperature": "Higher values make answers more varied, lower values more focused.", + "com_config_desc_modelSpecs_list_preset_thinking": "Let the model think before answering.", + "com_config_desc_modelSpecs_list_preset_thinkingBudget": "Most tokens the model can spend thinking.", + "com_config_desc_modelSpecs_list_preset_thinkingDisplay": "How the model's thinking is shown.", + "com_config_desc_modelSpecs_list_preset_thinkingLevel": "How much the model thinks before answering.", + "com_config_desc_modelSpecs_list_preset_topK": "Samples only from this many of the most likely tokens.", + "com_config_desc_modelSpecs_list_preset_topP": "Samples only from the most likely tokens whose probabilities add up to this value.", + "com_config_desc_modelSpecs_list_preset_top_p": "Samples only from the most likely tokens whose probabilities add up to this value.", + "com_config_desc_modelSpecs_list_preset_url_context": "Let the model read pages from URLs in the message.", + "com_config_desc_modelSpecs_list_preset_useResponsesApi": "Use OpenAI's Responses API instead of Chat Completions.", + "com_config_desc_modelSpecs_list_preset_verbosity": "How long and detailed answers are.", + "com_config_desc_modelSpecs_list_preset_web_search": "Let the model search the web with the provider's own search tool (apart from the spec's Web search capability).", + "com_config_desc_modelSpecs_list_subagents_agent_ids": "Agents the spec can hand work off to.", + "com_config_desc_modelSpecs_list_subagents_allowSelf": "Let the spec start copies of itself as subagents.", + "com_config_desc_modelSpecs_list_subagents_enabled": "Let the spec start subagents.", + "com_config_desc_modelSpecs_list_subagents_shareFiles": "Share the chat's files with its subagents.", "com_config_desc_modelSpecs_list_runInBackground": "Let tools run in the background: off, every tool, or only the tools listed.", "com_config_desc_modelSpecs_list_showIconInHeader": "Show the spec's icon in the chat header.", "com_config_desc_modelSpecs_list_showIconInMenu": "Show the spec's icon in the model menu.", diff --git a/src/types/changes.ts b/src/types/changes.ts index ad6c01df..74402b7a 100644 --- a/src/types/changes.ts +++ b/src/types/changes.ts @@ -17,7 +17,8 @@ export type ConfigChange = * its key afterwards, so `key !== identity` is a rename. `baseline` is the * entry as the editor loaded it: only what changed since then is applied, on * top of the entry as it is stored at save time. `index` places a new entry - * in a replace-semantics list (appended when absent). + * in a replace-semantics list (appended when absent), and moves an existing + * one there (a reordered list sends every kept entry with its position). */ | { op: 'upsertEntry'; diff --git a/src/types/fields.ts b/src/types/fields.ts index db19ecad..8190ebb1 100644 --- a/src/types/fields.ts +++ b/src/types/fields.ts @@ -248,6 +248,10 @@ export interface ArrayObjectFieldProps { getEntryTitle?: (item: ConfigValue, index: number) => string | null; /** Collapsed-row summary for an entry. */ renderSummary?: (item: ConfigValue, index: number) => ReactNode; + /** Shown before an entry's title (e.g. a model spec's icon). */ + renderLeading?: (item: ConfigValue, index: number) => ReactNode; + /** Each card's summary on its own line under the title. */ + stackedSummary?: boolean; /** Offer move up/down on each card (order matters, e.g. model specs). */ reorderable?: boolean; /** Text shown when the list is empty. */ @@ -329,6 +333,10 @@ export interface ObjectEntryCardProps { markers?: EntryMarkers; /** Collapsed-row summary; `null` hides it. Defaults to the entry's type/url/label. */ summary?: ReactNode; + /** Shown before the title (e.g. a model spec's icon). */ + leading?: ReactNode; + /** The summary on its own line under the title (rich summaries, narrow cards). */ + stackedSummary?: boolean; disabled?: boolean; /** Expanded on mount, or as soon as it turns true unless the admin toggled the card. */ defaultExpanded?: boolean; diff --git a/src/types/index.ts b/src/types/index.ts index f66b369f..fd64883d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,6 +16,7 @@ export type * from './scope'; export type * from './server'; export type * from './settings'; export type * from './shared'; +export type * from './specs'; export type * from './shell'; export type * from './theme'; export type * from './user'; diff --git a/src/types/specs.ts b/src/types/specs.ts new file mode 100644 index 00000000..ba4ca320 --- /dev/null +++ b/src/types/specs.ts @@ -0,0 +1,232 @@ +import type { ReactNode } from 'react'; +import type { ConfigRecord, ConfigValue, ControlWidth, SchemaField, SelectOption } from './config'; +import type { FieldRendererProps } from './config-ui'; + +/** + * How users pick models (MS-3), stored as `modelSpecs.enforce` and + * `modelSpecs.prioritize`: specs only, specs first, or endpoints first. + */ +export type SpecSelectionMode = 'enforce' | 'prioritize' | 'endpoints'; + +/** Which "new chat" default a spec is: none, the soft default, or the default (one of each per list). */ +export type SpecDefaultKind = 'none' | 'soft' | 'default'; + +/** A problem with one spec, or with the list (`index` -1). Errors block Save; warnings don't. */ +export interface SpecProblem { + index: number; + /** Path inside the spec (`label`, `preset.endpoint`, `default`). */ + field: string; + /** Issue code; its message is `com_config_issue_` (`required`, `spec_duplicate_name`). */ + code: string; + level: 'error' | 'warning'; + params?: Record; +} + +/** What spec validation needs to know about the rest of the configuration. */ +export interface SpecValidationContext { + /** Endpoint names a spec may use: built-in providers and custom endpoint names. Empty: unknown. */ + knownEndpoints: ReadonlySet; +} + +/** Control a preset parameter gets, from LibreChat's own parameter settings (`paramSettings`). */ +export type PresetParamKind = 'number' | 'boolean' | 'enum' | 'text' | 'textarea' | 'list'; + +/** One model parameter a spec's preset can set for its endpoint (MS-4). */ +export interface PresetParam { + key: string; + kind: PresetParamKind; + min?: number; + max?: number; + step?: number; + /** Choices of an enum parameter. */ + options?: string[]; + /** What LibreChat uses when the preset leaves the parameter unset. */ + defaultValue?: ConfigValue; +} + +/** The parameters an endpoint (and model) offers, as LibreChat's parameter panel resolves them. */ +export interface PresetParamSet { + /** `paramSettings` key the parameters come from (`anthropic`, `bedrock-anthropic`, `custom`). */ + settingsKey: string | null; + params: PresetParam[]; +} + +/** Option lists the spec editor offers: endpoints, an endpoint's models, MCP servers, groups. */ +export interface SpecOptions { + endpoints: SelectOption[]; + /** Built-in providers and custom endpoint names the configuration defines. */ + knownEndpoints: ReadonlySet; + /** Names of custom endpoints (`endpoints.custom[].name`). */ + customEndpoints: ReadonlySet; + mcpServers: SelectOption[]; + groups: SelectOption[]; + /** Models to suggest for an endpoint (its configured list and other specs' models). */ + modelsFor: (endpoint: string) => SelectOption[]; + /** Custom endpoint settings by name (its `customParams` decide the parameter set). */ + customEndpoint: (name: string) => ConfigRecord | undefined; +} + +/** List-wide actions a spec editor needs (a default is unique across the list). */ +export interface SpecListActions { + setDefault: (index: number, kind: SpecDefaultKind) => void; +} + +/** Context the spec editor and summaries read (one per list). */ +export interface SpecListContextValue { + items: ConfigValue[]; + problems: SpecProblem[]; + options: SpecOptions; + actions: SpecListActions; + /** Names the specs had when loaded: renaming one of them breaks links and conversations. */ + savedNames: ReadonlySet; + /** "Set values only": rows and groups without a value are left out. */ + configuredOnly: boolean; +} + +export type ModelSpecsRendererProps = FieldRendererProps; + +export interface SpecsSettingsProps extends FieldRendererProps { + /** The specs as shown (pending edits included), for the preview line. */ + items: ConfigValue[]; + options: SpecOptions; +} + +export interface SpecListProps extends FieldRendererProps { + listField: SchemaField; + items: ConfigValue[]; + options: SpecOptions; + problems: SpecProblem[]; +} + +export interface SpecSummaryProps { + spec: ConfigValue; + index: number; +} + +export interface SpecIconProps { + iconURL: ConfigValue; + label: string; + size?: 'sm' | 'md'; +} + +/** One collapsible group of the spec editor (Basics, Model, Capabilities, …). */ +export interface SpecGroupProps { + title: string; + description?: string; + /** Data paths inside the group: it opens for a search hit or a field error there. */ + paths: string[]; + defaultExpanded?: boolean; + /** Settings the spec sets in this group, shown as a count while collapsed. */ + configuredCount?: number; + totalCount?: number; + children: ReactNode; +} + +export interface SpecEditorProps { + fields: SchemaField[]; + value: ConfigValue; + /** Data path of the spec (`modelSpecs.list.3`). */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + disabled?: boolean; + editSessionId?: number; +} + +export interface PresetEditorProps { + presetField: SchemaField; + preset: ConfigRecord; + /** The parameters the spec's endpoint and model offer (`presetParams`). */ + params: PresetParam[]; + /** Data path of the preset (`modelSpecs.list.3.preset`). */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + /** Replaces the whole preset (the YAML escape hatch). */ + onReplace: (preset: ConfigRecord) => void; + disabled?: boolean; + editSessionId?: number; +} + +/** A row the spec editor renders with its own control (not the generic field control). */ +export interface SpecFieldRowProps { + path: string; + label: string; + description?: string; + required?: boolean; + /** The spec problem shown under the control (falls back to the save pipeline's error). */ + problem?: SpecProblem; + control: ReactNode; + width?: ControlWidth; + /** + * The field is empty on a spec added in this session: a missing value isn't + * flagged until the admin leaves the field (the card badge still counts it). + */ + quietWhenEmpty?: boolean; + /** More data paths the row stands for (the Default row is also the soft default), for the tab filter. */ + matchPaths?: string[]; +} + +/** A labelled group of choices (rendered as an ``). */ +export interface SpecOptionGroup { + label: string; + options: SelectOption[]; +} + +/** Picks a set of values from known choices as removable chips (Added endpoints, MCP servers). */ +export interface ChipPickerProps { + id: string; + values: string[]; + groups: SpecOptionGroup[]; + onChange: (values: string[]) => void; + /** Called instead of emitting `[]` when the last chip is removed (returns false when it can't be). */ + onClear?: () => boolean; + disabled?: boolean; + /** Label of the add picker ("Add endpoint…"). */ + addLabel: string; + /** Text of a chip whose value matches no choice ("Foo (not configured)"). */ + unknownLabel: (value: string) => string; + 'aria-label': string; + 'aria-describedby'?: string; +} + +/** A single choice from grouped options, keeping (and flagging) a value that matches none. */ +export interface GroupedSelectProps { + id: string; + value: string | undefined; + groups: SpecOptionGroup[]; + onChange: (value: string) => void; + placeholder: string; + unknownLabel: (value: string) => string; + required?: boolean; + disabled?: boolean; + 'aria-describedby'?: string; +} + +/** A note in the specs section: information, a warning, or the "Users will see" preview. */ +export interface SpecNoteProps { + tone: 'info' | 'warning' | 'preview'; + icon?: ReactNode; + /** Buttons after the text (Revert list, Customize list, Open Model select). */ + action?: ReactNode; + children: ReactNode; +} + +/** Props of one spec editor field with its own control (Label, Name, Icon, Group, Default, MCP). */ +export interface SpecFieldProps { + field?: SchemaField; + spec: ConfigRecord; + index: number; + /** Data path of the spec (`modelSpecs.list.3`). */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + disabled?: boolean; +} + +/** A spec field stored in its preset (Endpoint, Model, Agent). */ +export interface SpecPresetFieldProps extends SpecFieldProps { + onPresetChange: (key: string, value: ConfigValue) => void; +} + +/** The icon of the spec (`iconURL`) or of its group (`groupIcon`). */ +export interface SpecIconFieldProps extends Omit { + keyName?: 'iconURL' | 'groupIcon'; +} diff --git a/src/utils/collections.test.ts b/src/utils/collections.test.ts index d70998bd..fc3f6c08 100644 --- a/src/utils/collections.test.ts +++ b/src/utils/collections.test.ts @@ -282,6 +282,61 @@ describe('resolveCollectionOps — merge-by-key (endpoints.custom)', () => { expect(resolve([upsert('OpenRouter Direct', null, entry)]).next).toEqual([entry]); }); + it('resolves swapped and chained renames of DB-only entries as a whole', () => { + const stored = [ + { name: 'AdminA', baseURL: 'https://a', apiKey: 'user_provided' }, + { name: 'AdminB', baseURL: 'https://b', apiKey: 'user_provided' }, + ]; + const swapped = resolve( + [ + upsert('AdminB', 'AdminA', { ...stored[0], name: 'AdminB' }, stored[0]), + upsert('AdminA', 'AdminB', { ...stored[1], name: 'AdminA' }, stored[1]), + ], + stored, + ); + expect(swapped.issues).toEqual([]); + expect(swapped.next).toEqual([ + { ...stored[0], name: 'AdminB' }, + { ...stored[1], name: 'AdminA' }, + ]); + const chained = resolve( + [ + upsert('AdminB', 'AdminA', { ...stored[0], name: 'AdminB' }, stored[0]), + upsert('AdminC', 'AdminB', { ...stored[1], name: 'AdminC' }, stored[1]), + upsert('AdminA', null, { name: 'AdminA', baseURL: 'https://new' }), + ], + stored, + ); + expect(chained.issues).toEqual([]); + expect(chained.next).toEqual([ + { ...stored[0], name: 'AdminB' }, + { ...stored[1], name: 'AdminC' }, + { name: 'AdminA', baseURL: 'https://new' }, + ]); + }); + + it('still rejects a rename onto a name the final list repeats, without temporary names', () => { + const stored = [ + { name: 'AdminA', baseURL: 'https://a', apiKey: 'user_provided' }, + { name: 'Secret EP', baseURL: 'https://s', apiKeyPreview: 'sk-abc...1234' }, + ]; + const result = resolve( + [ + upsert('Secret EP', 'AdminA', { ...stored[0], name: 'Secret EP' }, stored[0]), + upsert('AdminA', 'Secret EP', { name: 'AdminA', baseURL: 'https://s' }, stored[1]), + ], + stored, + ); + expect(result.issues.map((issue) => issue.code)).toEqual(['SECRET_REQUIRED_FOR_RENAME']); + const names = (result.next ?? []).map((entry) => (entry as t.ConfigRecord).name); + expect(names).toEqual(['Secret EP', 'Secret EP']); + const onYaml = resolve( + [upsert('Moonshot', 'AdminA', { ...stored[0], name: 'Moonshot' }, stored[0])], + stored, + ); + expect(onYaml.issues.map((issue) => issue.code)).toEqual(['DUPLICATE_ENTRY']); + }); + it('requires a new API key to rename an entry whose stored key is encrypted (SV-10)', () => { const stored = [{ name: 'Secret EP', baseURL: 'https://s', apiKeyPreview: 'sk-abc...1234' }]; const value = { name: 'Renamed EP', baseURL: 'https://s' }; @@ -403,4 +458,57 @@ describe('resolveCollectionOps — replace (modelSpecs.list)', () => { const reverted = resolve([specOp('gpt', 'gpt', yamlSpecs[0], own[0])], own, yamlSpecs, specs); expect(reverted).toMatchObject({ next: undefined, changed: true }); }); + + it('moves existing specs to their index, so a reorder alone is saved', () => { + const moves = [ + { ...specOp('claude', 'claude', yamlSpecs[1], yamlSpecs[1]), index: 0 }, + { ...specOp('gpt', 'gpt', yamlSpecs[0], yamlSpecs[0]), index: 1 }, + ] as t.EntryChange[]; + const result = resolve(moves, undefined, yamlSpecs, specs); + expect(result.changed).toBe(true); + expect(result.next?.map((s) => (s as t.ConfigRecord).name)).toEqual(['claude', 'gpt']); + const back = resolve( + [ + { ...specOp('gpt', 'gpt', yamlSpecs[0], yamlSpecs[0]), index: 0 }, + { ...specOp('claude', 'claude', yamlSpecs[1], yamlSpecs[1]), index: 1 }, + ] as t.EntryChange[], + [yamlSpecs[1], yamlSpecs[0]], + yamlSpecs, + specs, + ); + expect(back).toMatchObject({ next: undefined, changed: true }); + }); + + it('swaps the names of two specs in one batch', () => { + const swap = [ + specOp('claude', 'gpt', { ...yamlSpecs[0], name: 'claude' }, yamlSpecs[0]), + specOp('gpt', 'claude', { ...yamlSpecs[1], name: 'gpt' }, yamlSpecs[1]), + ]; + const result = resolve(swap, yamlSpecs, yamlSpecs, specs); + expect(result.issues).toEqual([]); + expect(result.next).toEqual([ + { ...yamlSpecs[0], name: 'claude' }, + { ...yamlSpecs[1], name: 'gpt' }, + ]); + const clash = resolve( + [specOp('claude', 'gpt', { ...yamlSpecs[0], name: 'claude' }, yamlSpecs[0])], + yamlSpecs, + yamlSpecs, + specs, + ); + expect(clash.issues.map((issue) => issue.code)).toEqual(['DUPLICATE_ENTRY']); + }); + + it('rebuilds a reorder with an added and a removed spec in one batch', () => { + const third = { name: 'mini', label: 'Mini', preset: { endpoint: 'openAI' } }; + const ops = [ + { ...specOp('new', null, { name: 'new', label: 'New' }), index: 0 }, + { ...specOp('claude', 'claude', yamlSpecs[1], yamlSpecs[1]), index: 1 }, + { ...specOp('mini', 'mini', third, third), index: 2 }, + { op: 'removeEntry', path: 'modelSpecs.list', key: 'gpt', identity: 'gpt' }, + ] as t.EntryChange[]; + const result = resolve(ops, undefined, [...yamlSpecs, third], specs); + expect(result.issues).toEqual([]); + expect(result.next?.map((s) => (s as t.ConfigRecord).name)).toEqual(['new', 'claude', 'mini']); + }); }); diff --git a/src/utils/collections.ts b/src/utils/collections.ts index ff3405f2..90595857 100644 --- a/src/utils/collections.ts +++ b/src/utils/collections.ts @@ -213,6 +213,49 @@ interface ResolveState { issues: t.ConfigIssue[]; warnings: t.ConfigWarning[]; effective: number; + /** Identities a later op of the batch renames. */ + pendingRenames: Set; + /** The temporary key of each entry moved aside until its own rename (`moveAside`). */ + aside: Map; +} + +const ASIDE_KEY_PREFIX = '\u0000aside:'; + +/** Where the entry loaded as `identity` is now (under its temporary key while moved aside). */ +function locate(state: ResolveState, identity: string): number { + const name = state.aside.get(identity) ?? identity; + return indexOfKey(state.entries, state.input.collection.key, name); +} + +/** + * Frees `name` for a rename or an add when the entry holding it is renamed by + * a later op of the batch (a swap, `A → B` with `B → A`, or a chain): that + * entry waits under a temporary key until its own rename runs, so the batch + * is resolved as a whole and only names the final list repeats collide. An + * entry the lower layer also defines keeps its name (it can't be renamed). + */ +function moveAside(state: ResolveState, name: string, lowerEntries: t.ConfigValue[]): void { + if (!state.pendingRenames.has(name) || state.aside.has(name)) return; + const { key } = state.input.collection; + const index = indexOfKey(state.entries, key, name); + if (index < 0 || indexOfKey(lowerEntries, key, name) >= 0) return; + const temporary = `${ASIDE_KEY_PREFIX}${name}`; + state.entries = state.entries.map((entry, i) => + i === index ? withKey(entry, key, temporary) : entry, + ); + state.aside.set(name, temporary); +} + +/** Gives entries still moved aside (their rename was refused) their names back. */ +function restoreAside(state: ResolveState): void { + if (state.aside.size === 0) return; + const { key } = state.input.collection; + const identities = new Map([...state.aside].map(([identity, name]) => [name, identity])); + state.entries = state.entries.map((entry) => { + const identity = identities.get(getEntryKey(entry, key) ?? ''); + return identity === undefined ? entry : withKey(entry, key, identity); + }); + state.aside.clear(); } function entryIssue( @@ -315,6 +358,7 @@ function upsertByKey( const { collection } = state.input; const { key } = collection; if (op.identity === null) { + moveAside(state, op.key, lowerEntries); if (hasKey(state, lowerEntries, op.key)) { state.issues.push( entryIssue(collection, 'DUPLICATE_ENTRY', op.key, `"${op.key}" already exists`), @@ -327,7 +371,7 @@ function upsertByKey( } const identity = op.identity; const lowerEntry = lowerEntries.find((entry) => getEntryKey(entry, key) === identity); - const index = indexOfKey(state.entries, key, identity); + const index = locate(state, identity); if (op.key !== identity) { if (lowerEntry !== undefined) { state.issues.push(immutableIssue(state.input, identity, true)); @@ -337,6 +381,7 @@ function upsertByKey( state.issues.push(notFoundIssue(collection, identity)); return; } + moveAside(state, op.key, lowerEntries); if (hasKey(state, lowerEntries, op.key)) { state.issues.push(renameTakenIssue(collection, identity, op.key)); return; @@ -354,6 +399,7 @@ function upsertByKey( } const renamed = withKey(desiredEntry(op, state.entries[index], state), key, op.key); state.entries = state.entries.map((entry, i) => (i === index ? renamed : entry)); + state.aside.delete(identity); state.effective++; return; } @@ -457,7 +503,7 @@ function removeEntry(op: RemoveEntryOp, state: ResolveState, lowerEntries: t.Con state.issues.push(immutableIssue(state.input, op.identity, false)); return; } - const index = indexOfKey(state.entries, key, op.identity); + const index = locate(state, op.identity); if (index < 0) { state.issues.push(notFoundIssue(collection, op.identity)); return; @@ -469,7 +515,10 @@ function removeEntry(op: RemoveEntryOp, state: ResolveState, lowerEntries: t.Con function upsertInList(op: UpsertEntryOp, state: ResolveState): void { const { collection } = state.input; const { key } = collection; - const taken = (name: string) => indexOfKey(state.entries, key, name) >= 0; + const taken = (name: string) => { + moveAside(state, name, []); + return indexOfKey(state.entries, key, name) >= 0; + }; if (op.identity === null) { if (taken(op.key)) { state.issues.push( @@ -483,7 +532,7 @@ function upsertInList(op: UpsertEntryOp, state: ResolveState): void { state.effective++; return; } - const index = indexOfKey(state.entries, key, op.identity); + const index = locate(state, op.identity); if (index < 0) { state.issues.push(notFoundIssue(collection, op.identity)); return; @@ -492,9 +541,13 @@ function upsertInList(op: UpsertEntryOp, state: ResolveState): void { state.issues.push(renameTakenIssue(collection, op.identity, op.key)); return; } + state.aside.delete(op.identity); const updated = withKey(desiredEntry(op, state.entries[index], state), key, op.key); - if (isConfigValueEqual(updated, state.entries[index])) return; - state.entries = state.entries.map((entry, i) => (i === index ? updated : entry)); + const changed = !isConfigValueEqual(updated, state.entries[index]); + const others = state.entries.filter((_, i) => i !== index); + const at = op.index === undefined ? index : Math.min(Math.max(op.index, 0), others.length); + if (!changed && at === index) return; + state.entries = [...others.slice(0, at), updated, ...others.slice(at)]; state.effective++; } @@ -512,7 +565,8 @@ function upsertInList(op: UpsertEntryOp, state: ResolveState): void { * `replace` (model specs): the result is the whole list, starting from the * target's own list or, without one, the list it inherits; taking over an * inherited list is reported as an `ownershipChange` warning, and a list - * equal to the inherited one is no override at all. + * equal to the inherited one is no override at all. An upsert with an `index` + * also moves its entry there (applied in order, they rebuild a reordering). * * An identity missing from the current layers is a conflict (`ENTRY_NOT_FOUND`). */ @@ -533,12 +587,22 @@ export function resolveCollectionOps(input: t.CollectionOpsInput): t.CollectionR issues: [], warnings: [], effective: 0, + pendingRenames: new Set( + input.ops.flatMap((op) => + op.op === 'upsertEntry' && op.identity !== null && op.key !== op.identity + ? [op.identity] + : [], + ), + ), + aside: new Map(), }; for (const op of input.ops) { + if (op.op === 'upsertEntry' && op.identity !== null) state.pendingRenames.delete(op.identity); if (op.op === 'removeEntry') removeEntry(op, state, lowerEntries); else if (byKey) upsertByKey(op, state, lowerEntries, hiddenSecrets); else upsertInList(op, state); } + restoreAside(state); const next = resolveNext(state.entries, own, lowerEntries, byKey); if (!byKey && own === undefined && next !== undefined) { state.warnings.push({ path: collection.path, warning: 'ownershipChange' }); diff --git a/src/utils/values.test.ts b/src/utils/values.test.ts index 39fb7553..989d4746 100644 --- a/src/utils/values.test.ts +++ b/src/utils/values.test.ts @@ -8,6 +8,7 @@ import { scopeLayersFixture, } from '@/test/layers'; import { deriveFieldState, deriveFieldValue, resolveShadowedBy } from './values'; +import { mergeScopeLayer } from './layers'; function data(overrides: Partial = {}): t.ConfigValuesData { return { @@ -337,6 +338,93 @@ describe('deriveFieldValue', () => { }); }); +describe('deriveFieldState for model specs (a list replaced whole)', () => { + const base = baseConfigFixture({ + dbOverrides: { + modelSpecs: { + list: [ + { name: 'spec-b', label: 'Spec B' }, + { name: 'spec-a', label: 'Spec A (admin)' }, + ], + }, + }, + }); + const data = valuesDataFixture(baseLayersFixture(base)); + + it("reads each spec's lower value from the same spec, not from its index", () => { + expect(deriveFieldState('modelSpecs.list.0.label', data)).toMatchObject({ + value: 'Spec B', + source: 'admin', + revertTarget: { value: undefined, source: 'default' }, + }); + expect(deriveFieldState('modelSpecs.list.1.label', data)).toMatchObject({ + value: 'Spec A (admin)', + source: 'admin', + revertTarget: { value: 'Spec A', source: 'yaml' }, + }); + }); +}); + +describe('deriveFieldState for a spec the owned list leaves a field out of', () => { + const yaml: t.ConfigRecord = { + modelSpecs: { list: [{ name: 'spec-a', label: 'Spec A', description: 'From YAML' }] }, + }; + const owned: t.ConfigRecord = { + modelSpecs: { list: [{ name: 'spec-a', label: 'Spec A (admin)' }] }, + }; + + it('does not present librechat.yaml’s field as what Base users get (Base owns the list)', () => { + const layers = baseLayersFixture( + baseConfigFixture({ + yamlConfig: yaml, + yamlPaths: ['modelSpecs.list'], + dbOverrides: owned, + config: mergeScopeLayer(yaml, owned), + }), + ); + expect( + deriveFieldState('modelSpecs.list.0.description', valuesDataFixture(layers)), + ).toMatchObject({ + value: undefined, + effective: undefined, + source: 'default', + isConfigured: false, + isOverride: false, + }); + expect(deriveFieldState('modelSpecs.list.0.label', valuesDataFixture(layers))).toMatchObject({ + value: 'Spec A (admin)', + source: 'admin', + revertTarget: { value: 'Spec A', source: 'yaml' }, + }); + }); + + it('does not present Base’s field as what a profile gets (the profile owns the list)', () => { + const base = baseConfigFixture({ + yamlConfig: yaml, + yamlPaths: ['modelSpecs.list'], + dbOverrides: {}, + config: yaml, + }); + const layers = scopeLayersFixture( + scopeConfigFixture({ + resolvedConfig: {}, + changedPaths: ['modelSpecs.list'], + overrides: owned, + tombstones: [], + }), + base, + ); + expect( + deriveFieldState('modelSpecs.list.0.description', valuesDataFixture(layers)), + ).toMatchObject({ + value: undefined, + effective: undefined, + source: 'default', + inherited: { value: 'From YAML' }, + }); + }); +}); + describe('deriveFieldState for custom endpoints (merged by name)', () => { const base = baseConfigFixture({ dbOverrides: { diff --git a/src/utils/values.ts b/src/utils/values.ts index 632791f9..0bbd67f8 100644 --- a/src/utils/values.ts +++ b/src/utils/values.ts @@ -1,9 +1,8 @@ import type * as t from '@/types'; import { hasPathOrAncestor, pathIndexOverlaps } from './paths'; import { getValueAtPath, isConfigRecord } from './format'; -import { getReadOnlyReason } from '@/constants'; +import { getReadOnlyReason, KEYED_COLLECTIONS } from '@/constants'; import { toSecretPreviewKey } from './secrets'; -import { ARRAY_MERGE_KEYS } from './layers'; /** * Value a field control shows: a pending edit first, then the merged tree @@ -133,6 +132,8 @@ interface KeyedLocation { key: t.ConfigValue; /** The path inside the entry ('' for the entry itself). */ rest: string; + /** LibreChat replaces the list whole (model specs) instead of merging it by key. */ + replaced: boolean; } /** Reads one path from every layer: by path, or by entry key inside keyed lists. */ @@ -142,14 +143,22 @@ interface PathReader { valueIn: (root: t.ConfigValue) => t.ConfigValue; /** Whether a layer sets the path (or something inside it). */ sets: (root: t.ConfigValue, index: t.PathIndex) => boolean; + /** + * Whether a layer holds its own copy of a list LibreChat replaces whole + * (model specs): the layers beneath it then don't reach the entry at all, + * though their entry of the same name still serves as revert metadata. + */ + replaces: (root: t.ConfigValue) => boolean; } /** * Where `path` points inside a keyed list, identified by the entry's key in - * the displayed tree (indices differ between layers; names do not). + * the displayed tree (indices differ between layers; names do not). Lists + * LibreChat replaces whole (model specs) are read the same way: the layer + * beneath's value for a spec is that same spec's, not whichever sits at its index. */ function locateKeyed(path: string, displayRoot: t.ConfigValue): KeyedLocation | null { - for (const [arrayPath, keyField] of Object.entries(ARRAY_MERGE_KEYS)) { + for (const { path: arrayPath, key: keyField, merge } of KEYED_COLLECTIONS.values()) { if (!path.startsWith(`${arrayPath}.`)) continue; const tail = path.slice(arrayPath.length + 1); const dot = tail.indexOf('.'); @@ -162,6 +171,7 @@ function locateKeyed(path: string, displayRoot: t.ConfigValue): KeyedLocation | keyField, key: entry[keyField], rest: dot === -1 ? '' : tail.slice(dot + 1), + replaced: merge === 'replace', }; } return null; @@ -193,6 +203,7 @@ function createReader(path: string, displayRoot: t.ConfigValue): PathReader { index.ancestors.has(path) || index.paths.has(path) || (pathIndexOverlaps(index, path) && getValueAtPath(root, path) !== undefined), + replaces: () => false, }; } /** A redacted secret is returned as its preview companion (`apiKey` → `apiKeyPreview`). */ @@ -205,9 +216,22 @@ function createReader(path: string, displayRoot: t.ConfigValue): PathReader { sets: (root) => keyedValue(root, location) !== undefined || (preview != null && keyedValue(root, preview) !== undefined), + replaces: (root) => + location.replaced && Array.isArray(getValueAtPath(root, location.arrayPath)), }; } +/** What reaches users once a layer replaces the whole list: only the schema default. */ +function replacedLower(path: string, layers: t.ValueLayers, reader: PathReader): t.RevertTarget { + const value = layers.schemaDefaults[path] ?? layers.schemaDefaults[reader.schemaPath]; + return { value, source: 'default' }; +} + +/** The layers that still reach users once a layer replaces the whole list. */ +function defaultsOnly(stack: t.FieldLayer[]): t.FieldLayer[] { + return stack.filter((layer) => layer.source === 'default'); +} + /** The layers under any override: schema default, what LibreChat fills in, librechat.yaml. */ function readLowerLayers( path: string, @@ -239,26 +263,34 @@ function defaultAt(path: string, layers: t.ValueLayers): t.ConfigValue { return layers.runtimeDefaultAt?.(path) ?? layers.schemaDefaults[path]; } -/** What Base users get at `path`, and from which layer. */ +/** + * What Base users get at `path`, and from which layer. When the base + * overrides replace the whole list (model specs), librechat.yaml's entry of + * the same name doesn't reach users: it stays only as the revert target. + */ function readBase(path: string, layers: t.ValueLayers, reader: PathReader): BaseReadout { - const stack: t.FieldLayer[] = []; - const { lower, yamlSets } = readLowerLayers(path, layers, reader, stack); + const lowerStack: t.FieldLayer[] = []; + const { lower, yamlSets } = readLowerLayers(path, layers, reader, lowerStack); + const replaced = reader.replaces(layers.baseOverrides); + const reaching = replaced ? replacedLower(path, layers, reader) : lower; + const stack = replaced ? defaultsOnly(lowerStack) : lowerStack; + const reachingYaml = yamlSets && !replaced; const isOverride = reader.sets(layers.baseOverrides, layers.baseOverridePaths); const isHidden = !isOverride && hasPathOrAncestor(layers.baseTombstones, path); const baseValue = reader.valueIn(layers.base); if (isOverride) stack.push({ source: 'admin', value: baseValue }); if (isHidden) stack.push({ source: 'admin', value: undefined, hidden: true }); - const effective = baseValue ?? (isHidden ? defaultAt(path, layers) : lower.value); - let source: t.ProvenanceSource = lower.source; + const effective = baseValue ?? (isHidden ? defaultAt(path, layers) : reaching.value); + let source: t.ProvenanceSource = reaching.source; if (isOverride || isHidden) { source = 'admin'; - } else if (!yamlSets && baseValue !== undefined && lower.source === 'default') { - source = sameValue(baseValue, lower.value) ? 'default' : 'runtimeDefault'; + } else if (!reachingYaml && baseValue !== undefined && reaching.source === 'default') { + source = sameValue(baseValue, reaching.value) ? 'default' : 'runtimeDefault'; } return { effective, source, - isConfigured: yamlSets || isOverride || isHidden, + isConfigured: reachingYaml || isOverride || isHidden, isOverride, isHidden, lower, @@ -339,12 +371,16 @@ function deriveScopeModeState( const isHidden = visibility ? visibility === 'hide' : !isOverride && hasPathOrAncestor(scope.tombstones, path); + /** A profile's own copy of a list replaced whole (model specs): Base's entry doesn't reach its users. */ + const replaced = reader.replaces(scope.overrides); + const reaching = replaced ? replacedLower(path, layers, reader) : inherited; const merged = reader.valueIn(layers.effective); - const effective = merged ?? (isHidden ? defaultAt(path, layers) : inherited.value); - const stack = [...base.stack]; + const effective = merged ?? (isHidden ? defaultAt(path, layers) : reaching.value); + const stack = replaced ? defaultsOnly(base.stack) : [...base.stack]; if (isOverride) stack.push({ source: 'scope', value: merged }); if (isHidden) stack.push({ source: 'scope', value: undefined, hidden: true }); const ownsValue = isOverride || isHidden; + const unownedSource: t.ProvenanceSource = replaced ? 'default' : 'inherited'; const revertTarget: t.RevertTarget | undefined = ownsValue ? { value: inherited.value, source: 'inherited' } : undefined; @@ -358,7 +394,7 @@ function deriveScopeModeState( return { value: pending.isEdited ? pending.value : (shown ?? effective), effective, - source: ownsValue ? 'scope' : 'inherited', + source: ownsValue ? 'scope' : unownedSource, ...(revertTarget && { revertTarget }), isConfigured: ownsValue, isOverride,