diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 085b4a0..1805e8f 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -448,6 +448,30 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi }); }, []); + /** Drops pending sub-edits (so the diff dialog doesn't show a partial state) + * and writes a single `undefined` at the section path; on save this routes + * through `resetBaseConfigFieldFn` → `$unset overrides.`. */ + const handleResetSection = useCallback((sectionPath: string) => { + if (!sectionPath) return; + startTransition(() => { + setTouchedPaths((prev) => { + const next = new Set(prev); + next.add(sectionPath); + return next; + }); + setEditedValues((prev) => { + const next: t.FlatConfigMap = {}; + const prefix = `${sectionPath}.`; + for (const [k, v] of Object.entries(prev)) { + if (k === sectionPath || k.startsWith(prefix)) continue; + next[k] = v; + } + next[sectionPath] = undefined; + return next; + }); + }); + }, []); + const handleConfirmSave = useCallback(async () => { if (saving) return; const touched = [...touchedPaths].filter((p) => p in editedValues); @@ -811,6 +835,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi editedValues={editedValues} onFieldChange={handleFieldChange} onResetField={handleResetField} + onResetSection={handleResetSection} profileMap={profileMap} previewMode={false} previewScope={editingScope} diff --git a/src/components/configuration/ConfigSection.tsx b/src/components/configuration/ConfigSection.tsx index 605107f..3bacd50 100644 --- a/src/components/configuration/ConfigSection.tsx +++ b/src/components/configuration/ConfigSection.tsx @@ -1,6 +1,6 @@ import { Icon } from '@clickhouse/click-ui'; import { useState, useCallback, useEffect, useRef } from 'react'; -import type { ReactNode } from 'react'; +import type { ReactNode, MouseEvent, KeyboardEvent } from 'react'; import type * as t from '@/types'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; @@ -34,6 +34,9 @@ export function ConfigSection({ defaultExpanded, inline, showConfiguredOnly, + sectionPath, + onResetSection, + hasOverrides, }: t.ConfigSectionProps) { const localize = useLocalize(); const [isExpanded, setIsExpanded] = useState(() => defaultExpanded ?? configuredCount > 0); @@ -63,9 +66,21 @@ export function ConfigSection({ }; }, []); + const handleResetClick = useCallback( + (e: MouseEvent | KeyboardEvent) => { + e.stopPropagation(); + if (!sectionPath || !onResetSection) return; + const message = localize('com_config_section_reset_confirm', { section: title }); + if (typeof window !== 'undefined' && !window.confirm(message)) return; + onResetSection(sectionPath); + }, + [sectionPath, onResetSection, localize, title], + ); + if (hidden) return null; const hasConfigured = configuredCount > 0; + const showResetSection = !!(sectionPath && onResetSection && hasOverrides); if (inline) { return ( @@ -76,7 +91,14 @@ export function ConfigSection({ >
- {title} + + + {title} + + {showResetSection && ( + + )} + {description && ( {description} )} @@ -123,6 +145,9 @@ export function ConfigSection({ {configuredCount}/{totalCount} )} + {showResetSection && ( + + )} {description && ( @@ -150,3 +175,30 @@ export function ConfigSection({ ); } + +function ResetSectionButton({ + onClick, + title, +}: { + onClick: (e: MouseEvent | KeyboardEvent) => void; + title: string; +}) { + const localize = useLocalize(); + return ( + + ); +} diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index 7ce6520..54210f9 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { MultiAccordion } from '@clickhouse/click-ui'; +import { Icon, MultiAccordion } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { SECTION_RENDERERS, SELF_CONTAINED_SECTION_RENDERERS } from './sections'; import { FieldRenderer, SingleFieldRenderer } from './FieldRenderer'; @@ -38,6 +38,7 @@ export function ConfigTabContent({ editedValues, onFieldChange, onResetField, + onResetSection, profileMap, previewMode, previewScope, @@ -83,6 +84,21 @@ export function ConfigTabContent({ return result; }, [filtering, previewChangedPaths, sections]); + const sectionHasOverrides = useMemo(() => { + const result: Record = {}; + const editedPaths = Object.keys(editedValues); + for (const section of sections) { + const sectionPath = section.schemaKey ?? section.id; + const prefix = `${sectionPath}.`; + const overridden = + (dbOverridePaths && + [...dbOverridePaths].some((p) => p === sectionPath || p.startsWith(prefix))) || + editedPaths.some((p) => p === sectionPath || p.startsWith(prefix)); + result[section.id] = !!overridden; + } + return result; + }, [sections, dbOverridePaths, editedValues]); + const sectionCounts = useMemo(() => { return sections.map((s) => { const key = s.schemaKey ?? s.id; @@ -275,6 +291,7 @@ export function ConfigTabContent({ if (group.kind === 'inline') { const { section } = group; const counts = countsById[section.id]; + const sectionPath = section.schemaKey ?? section.id; return ( {renderSectionContent(section)} @@ -301,17 +323,34 @@ export function ConfigTabContent({ defaultValue={group.sections.map((s) => s.id)} data-top-level-accordion > - {group.sections.map((section) => ( - - {renderSectionContent(section)} - - ))} + {group.sections.map((section) => { + const sectionPath = section.schemaKey ?? section.id; + const canResetSection = + !!onResetSection && + !!permissions?.canEdit && + !fieldsDisabled && + !!sectionHasOverrides[section.id]; + const titleNode = canResetSection ? ( + + ) : ( + localize(section.titleKey) + ); + return ( + + {renderSectionContent(section)} + + ); + })} ); })} @@ -333,3 +372,44 @@ function hasChangedDescendant( } return false; } + +function AccordionSectionHeader({ + title, + sectionPath, + onResetSection, +}: { + title: string; + sectionPath: string; + onResetSection: (path: string) => void; +}) { + const localize = useLocalize(); + const triggerReset = () => { + const message = localize('com_config_section_reset_confirm', { section: title }); + if (typeof window !== 'undefined' && !window.confirm(message)) return; + onResetSection(sectionPath); + }; + return ( + + {title} + + + ); +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9719e25..db20bfe 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -93,6 +93,8 @@ "com_config_default": "Default", "com_config_reset_default": "Reset to default", "com_config_reset_confirm": "Reset \"{{field}}\" to its default value? This removes the DB override.", + "com_config_reset_section": "Reset section", + "com_config_section_reset_confirm": "Reset all overrides under \"{{section}}\"? Saving will remove every configured value in this section and revert to defaults from librechat.yaml.", "com_config_section_configured_count": "{{count}} of {{total}} configured", "com_config_default_hint": "(default: {{value}})", "com_config_entry_n": "Entry {{n}}", @@ -909,6 +911,7 @@ "com_a11y_rename_entry": "Rename {{name}}", "com_a11y_remove_override": "Remove override for {{name}}", "com_a11y_reset_to_default": "Reset {{name}} to default", + "com_a11y_reset_section": "Reset section {{name}}", "com_a11y_add_item": "Add {{name}}", "com_access_tab_capabilities": "Capabilities", "com_cap_title": "System capabilities", diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index b13e873..c185df4 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -68,6 +68,7 @@ export interface ConfigTabContentProps { editedValues: FlatConfigMap; onFieldChange: (path: string, value: ConfigValue) => void; onResetField?: (path: string) => void; + onResetSection?: (sectionPath: string) => void; profileMap?: Record; previewMode?: boolean; previewScope?: ConfigScope; @@ -131,6 +132,15 @@ export interface ConfigSectionProps { defaultExpanded?: boolean; inline?: boolean; showConfiguredOnly?: boolean; + /** + * Path of this section within the schema (e.g. `modelSpecs`, + * `endpoints.azureOpenAI`). When provided alongside `onResetSection` and at + * least one configured override under it, the header renders a "Reset + * section" affordance that atomically clears all overrides under the path. + */ + sectionPath?: string; + onResetSection?: (sectionPath: string) => void; + hasOverrides?: boolean; } export interface ConfirmSaveDialogProps {