mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* i18n: add settings reorganization keys
* feat(settings): add tab/section types and tab metadata
* feat(settings): add useSettingsContext guard hook
* feat(settings): add pure settings search filter with tests
* feat(settings): extract selectors and add control wrappers
* feat(settings): add setting registry, memory and billing controls, integrity test
* feat(settings): add Section and Advanced disclosure with test
* feat(settings): add content pane with tab and search views
* feat(settings): add sidebar and dialog shell with tests
* refactor(settings): wire new dialog and remove superseded containers
* fix(settings): restore speech external engine option, escape-to-clear search, results a11y
- SpeechControls.tsx: read sttExternal/ttsExternal from useGetCustomConfigSpeechQuery
instead of hardcoding false, so external engine options appear on qualifying deployments
- Sidebar: Escape clears search input when non-empty, stops propagation to avoid closing dialog
- Content: persistent aria-live="polite" wrapper covers both populated results and empty state
- context: useMemo on returned ctx object so Content's useMemo deps are referentially stable
- locales/README.md: update stale path from deleted General.tsx to Selectors.tsx
* refactor(settings): reorganize categories, remove advanced disclosure, add About
- Re-categorize settings into logical groups (username display -> Chat/Messages,
keep-screen-awake -> Accessibility, fork/prompts surfaced into Chat sections)
- Dissolve thin Personalization tab; move Memory into Data & Privacy
- Remove the Advanced collapsible; all settings always visible, destructive
actions grouped in an always-visible Danger zone
- Wire the new About tab into the registry-driven dialog
- Standardize spacing with bordered, evenly-divided section cards
- Use semantic text-text-* / border tokens so dark mode renders correctly
- Sync LangSelector language-loading indicator from dev
* feat(settings): move archived chats to the account menu
Add an Archived chats item to the account dropdown next to My Files,
opening the archived chats table in a modal. Removes it from the
settings dialog where it no longer fit the data/privacy grouping.
* feat(settings): polish About panel and use shared CopyButton
- Flatten the build-info into a single divided key/value list (drop the
redundant inner card now that it sits inside a section card)
- Replace the hand-rolled copy button with the shared animated CopyButton
- Shorten the copied label so it fits the button without clipping
* fix(settings): set primary text color on setting rows for dark mode
Leaf control labels rendered without a text color and fell back to the
browser default (black), making them invisible on the dark panel. Set
text-text-primary on the section and search-results row containers so
labels inherit a visible color, matching the old container behavior.
* fix(settings): use visible icon for dialog close button
The plain multiplication-sign close button had no text color and was
invisible on the dark panel. Replace it with the lucide X icon using
text-text-secondary/hover:text-text-primary so it shows in both themes.
* fix(nav): drop focus ring on account menu items, use hover background only
The account-settings popover drew a 2px ring around the active menu item.
Remove that override so items show only the standard hover background,
consistent with every other menu.
* fix(settings): replace native search clear with a real X button
The settings search used type=search, whose native WebKit clear control
rendered as a blue X. Switch to a text input and add a real lucide X
clear button styled text-text-secondary, shown only when there's a query.
* fix(speech): disable dependent dropdowns and switches when STT/TTS is off
Add a disabled prop to the shared Dropdown component, then gate the
speech engine/voice/language dropdowns and the automatic-playback switch
on their parent toggle (speechToText / textToSpeech), matching the
controls that already disabled correctly.
* feat(settings): mobile drill-in navigation for settings tabs
On small screens the horizontal scrolling tab row is replaced with a
full-width vertical list (with chevrons); tapping a tab drills into its
content with a Back header. Searching shows results full-width. Desktop
keeps the side-by-side sidebar + content layout unchanged.
* chore(settings): remove orphaned i18n keys, fix import order and review notes
- Drop the i18n keys left unused after the refactor (old Commands/Balance/
Personalization tab labels, the Speech simple/advanced labels, and the
former About section headings)
- Sort imports in the rebased files the lint-staged hook never touched
- Guard the language fallback against an empty navigator.languages
- Import the RefObject type instead of leaning on the React namespace
* feat(settings): searchable language dropdown
Add an opt-in searchable mode to the shared Dropdown (Ariakit Select +
Combobox) and use it for the language selector, which has 40+ options.
The trigger styling is unchanged so it stays consistent with the other
settings rows; only the popover gains a filter input.
Accessibility: the filtered listbox is labeled, the empty state is moved
out of the listbox and announced via an aria-live status region, and the
decorative selected-state checkmark is hidden from assistive tech.
* fix(settings): restore guards dropped in dialog refactor
- Fall back to the General tab when the active tab becomes hidden
(e.g. About when buildInfo is disabled) instead of rendering an
empty panel.
- Normalize a deprecated/invalid engineTTS (e.g. 'edge') back to
browser during speech init so read-aloud controls keep rendering.
- Hide the cloud browser voices toggle unless Browser TTS is active.
* test(e2e): match agent-creation toast exactly to avoid SR-announce collision
The agent builder spec asserted the creation toast with a non-exact
getByText, which also matched Radix Toast's transient role="status"
announce region ("Notification Successfully created ..."), causing a
strict-mode violation. Mirror the mcp spec by using { exact: true }.
* fix(settings): render the active panel as a tabpanel
Wrap the non-search settings body in Tabs.Content so the selected
panel gets role=tabpanel with Radix's id/aria-labelledby wiring,
resolving the aria-controls target on each tab trigger. Search
results stay a labeled live region (the tab list is hidden during
mobile search, so a tabpanel aria-labelledby would dangle).
197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Locator, Page } from '@playwright/test';
|
|
import type { AgentDetail } from './agents.helpers';
|
|
import {
|
|
cleanupAgent,
|
|
openAgentBuilder,
|
|
selectMockModel,
|
|
uniqueAgentName,
|
|
waitForPersistedAgent,
|
|
} from './agents.helpers';
|
|
import { MOCK_ENDPOINTS, mockReply, sendMessage } from './helpers';
|
|
|
|
const DESCRIPTION = 'Use this agent to verify LibreChat agent creation in mock end-to-end tests.';
|
|
const INSTRUCTIONS =
|
|
'Reply through the mock e2e model and keep this agent available for UI persistence checks.';
|
|
const MODEL_PARAMETERS = {
|
|
maxContextTokens: 32000,
|
|
maxOutputTokens: 4096,
|
|
temperature: 0.25,
|
|
topP: 0.8,
|
|
topK: 12,
|
|
resendFiles: false,
|
|
promptCache: true,
|
|
thinking: true,
|
|
thinkingBudget: 2000,
|
|
web_search: true,
|
|
fileTokenLimit: 12000,
|
|
};
|
|
const PERSISTED_MODEL_PARAMETERS = {
|
|
maxContextTokens: MODEL_PARAMETERS.maxContextTokens,
|
|
maxOutputTokens: MODEL_PARAMETERS.maxOutputTokens,
|
|
temperature: MODEL_PARAMETERS.temperature,
|
|
topP: MODEL_PARAMETERS.topP,
|
|
topK: MODEL_PARAMETERS.topK,
|
|
resendFiles: MODEL_PARAMETERS.resendFiles,
|
|
web_search: MODEL_PARAMETERS.web_search,
|
|
fileTokenLimit: MODEL_PARAMETERS.fileTokenLimit,
|
|
};
|
|
|
|
async function setSwitch(form: Locator, name: string, checked: boolean) {
|
|
const control = form.getByRole('switch', { name, exact: true });
|
|
await expect(control).toBeVisible();
|
|
if ((await control.getAttribute('aria-checked')) !== String(checked)) {
|
|
await control.click();
|
|
}
|
|
await expect(control).toHaveAttribute('aria-checked', String(checked));
|
|
}
|
|
|
|
async function fillAnthropicStyleModelParameters(page: Page) {
|
|
const form = page.getByRole('form', { name: 'Agent configuration form' });
|
|
|
|
await expect(form.getByLabel('Max Context Tokens')).toBeVisible();
|
|
await expect(form.getByLabel('Max Output Tokens')).toBeVisible();
|
|
await expect(form.getByText('Temperature', { exact: true })).toBeVisible();
|
|
await expect(form.getByText('Top P', { exact: true })).toBeVisible();
|
|
await expect(form.getByText('Top K', { exact: true })).toBeVisible();
|
|
await expect(form.getByText('Effort', { exact: true })).toBeVisible();
|
|
await expect(form.getByText('Thought Visibility', { exact: true })).toBeVisible();
|
|
|
|
await form
|
|
.locator('#maxContextTokens-dynamic-input')
|
|
.fill(`${MODEL_PARAMETERS.maxContextTokens}`);
|
|
await form.locator('#maxOutputTokens-dynamic-input').fill(`${MODEL_PARAMETERS.maxOutputTokens}`);
|
|
await form
|
|
.locator('#temperature-dynamic-setting-input-number')
|
|
.fill(`${MODEL_PARAMETERS.temperature}`);
|
|
await form.locator('#topP-dynamic-setting-input-number').fill(`${MODEL_PARAMETERS.topP}`);
|
|
await form.locator('#topK-dynamic-setting-input-number').fill(`${MODEL_PARAMETERS.topK}`);
|
|
await form.locator('#thinkingBudget-dynamic-input').fill(`${MODEL_PARAMETERS.thinkingBudget}`);
|
|
await form.locator('#fileTokenLimit-dynamic-input').fill(`${MODEL_PARAMETERS.fileTokenLimit}`);
|
|
|
|
await setSwitch(form, 'Resend Files', MODEL_PARAMETERS.resendFiles);
|
|
await setSwitch(form, 'Use Prompt Caching', MODEL_PARAMETERS.promptCache);
|
|
await setSwitch(form, 'Thinking', MODEL_PARAMETERS.thinking);
|
|
await setSwitch(form, 'Web Search', MODEL_PARAMETERS.web_search);
|
|
|
|
// DynamicInput/DynamicSlider values use a 450ms debounced form update.
|
|
await page.waitForTimeout(600);
|
|
}
|
|
|
|
async function expectAnthropicStyleModelParameters(page: Page) {
|
|
const form = page.getByRole('form', { name: 'Agent configuration form' });
|
|
|
|
await expect(form.locator('#maxContextTokens-dynamic-input')).toHaveValue(
|
|
`${MODEL_PARAMETERS.maxContextTokens}`,
|
|
);
|
|
await expect(form.locator('#maxOutputTokens-dynamic-input')).toHaveValue(
|
|
`${MODEL_PARAMETERS.maxOutputTokens}`,
|
|
);
|
|
await expect(form.locator('#temperature-dynamic-setting-input-number')).toHaveValue(
|
|
`${MODEL_PARAMETERS.temperature}`,
|
|
);
|
|
await expect(form.locator('#topP-dynamic-setting-input-number')).toHaveValue(
|
|
MODEL_PARAMETERS.topP.toFixed(2),
|
|
);
|
|
await expect(form.locator('#topK-dynamic-setting-input-number')).toHaveValue(
|
|
`${MODEL_PARAMETERS.topK}`,
|
|
);
|
|
await expect(form.locator('#thinkingBudget-dynamic-input')).toHaveValue(
|
|
`${MODEL_PARAMETERS.thinkingBudget}`,
|
|
);
|
|
await expect(form.locator('#fileTokenLimit-dynamic-input')).toHaveValue(
|
|
`${MODEL_PARAMETERS.fileTokenLimit}`,
|
|
);
|
|
|
|
await expect(form.getByRole('switch', { name: 'Resend Files', exact: true })).toHaveAttribute(
|
|
'aria-checked',
|
|
String(MODEL_PARAMETERS.resendFiles),
|
|
);
|
|
await expect(
|
|
form.getByRole('switch', { name: 'Use Prompt Caching', exact: true }),
|
|
).toHaveAttribute('aria-checked', String(MODEL_PARAMETERS.promptCache));
|
|
await expect(form.getByRole('switch', { name: 'Thinking', exact: true })).toHaveAttribute(
|
|
'aria-checked',
|
|
String(MODEL_PARAMETERS.thinking),
|
|
);
|
|
await expect(form.getByRole('switch', { name: 'Web Search', exact: true })).toHaveAttribute(
|
|
'aria-checked',
|
|
String(MODEL_PARAMETERS.web_search),
|
|
);
|
|
}
|
|
|
|
test.describe('agent builder', () => {
|
|
test('creates an agent, persists its configuration, and can chat with it', async ({ page }) => {
|
|
test.setTimeout(120000);
|
|
|
|
const agentName = uniqueAgentName('E2E Agent');
|
|
let createdAgentId: string | undefined;
|
|
|
|
try {
|
|
let form = await openAgentBuilder(page);
|
|
|
|
await form.getByLabel('Agent name').fill(agentName);
|
|
await form.getByLabel('Agent description').fill(DESCRIPTION);
|
|
await form.getByLabel('Agent instructions').fill(INSTRUCTIONS);
|
|
|
|
await selectMockModel(page);
|
|
await fillAnthropicStyleModelParameters(page);
|
|
await page
|
|
.getByRole('form', { name: 'Agent configuration form' })
|
|
.getByRole('button', {
|
|
name: 'Back to builder',
|
|
})
|
|
.click();
|
|
form = page.getByRole('form', { name: 'Agent configuration form' });
|
|
|
|
const [createResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === 'POST' &&
|
|
new URL(response.url()).pathname === '/api/agents' &&
|
|
response.status() === 201,
|
|
{ timeout: 30000 },
|
|
),
|
|
form.getByRole('button', { name: 'Create' }).click(),
|
|
]);
|
|
const createdAgent = (await createResponse.json()) as AgentDetail;
|
|
createdAgentId = createdAgent.id;
|
|
|
|
await expect(
|
|
page.getByText(`Successfully created ${agentName}`, { exact: true }),
|
|
).toBeVisible();
|
|
|
|
const persistedAgent = await waitForPersistedAgent(page, agentName, DESCRIPTION);
|
|
expect(persistedAgent).toMatchObject({
|
|
id: createdAgentId,
|
|
name: agentName,
|
|
description: DESCRIPTION,
|
|
instructions: INSTRUCTIONS,
|
|
provider: MOCK_ENDPOINTS[0].label,
|
|
model: MOCK_ENDPOINTS[0].model,
|
|
category: 'general',
|
|
});
|
|
expect(persistedAgent.model_parameters).toMatchObject(PERSISTED_MODEL_PARAMETERS);
|
|
|
|
form = await openAgentBuilder(page);
|
|
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
|
|
await page.getByRole('option', { name: agentName }).click();
|
|
|
|
await expect(form.getByLabel('Agent name')).toHaveValue(agentName);
|
|
await expect(form.getByLabel('Agent description')).toHaveValue(DESCRIPTION);
|
|
await expect(form.getByLabel('Agent instructions')).toHaveValue(INSTRUCTIONS);
|
|
|
|
await form.locator('label[for="provider"] + button').click();
|
|
await expectAnthropicStyleModelParameters(page);
|
|
await form.getByRole('button', { name: 'Back to builder' }).click();
|
|
|
|
await form.getByRole('button', { name: 'Select Agent' }).click();
|
|
|
|
const response = await sendMessage(page, `hello from ${agentName}`);
|
|
expect(response.ok()).toBeTruthy();
|
|
await expect(mockReply(page)).toBeVisible({ timeout: 30000 });
|
|
} finally {
|
|
await cleanupAgent(page, createdAgentId);
|
|
}
|
|
});
|
|
});
|