From b3cb816d2026cc44bb462f5c35ae35c779d1c074 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 10:21:16 +0000 Subject: [PATCH 1/2] feat(config): add YAML drift compare & merge tool Add a dedicated /configuration/diff page where a librechat.yaml can be pasted and compared against the values currently configured in LibreChat (base-file overrides + admin-panel DB overrides). Configured leaf paths missing from the pasted YAML are folded in as "drift" additions and the page emits a single valid merged YAML with a copy button, ready to save. When the pasted YAML and LibreChat disagree on a field, the merge is blocked and every conflicting field is listed with both values so it can be resolved in the source YAML. - computeConfigDrift / buildConfiguredValues utilities (+ unit tests) - parseConfigYamlFn server fn validating YAML against configSchema and returning the literal parsed values (no schema defaults injected) - ConfigDiffPage UI and route, "Compare & merge" header action --- .../configuration/ConfigDiffPage.tsx | 425 ++++++++++++++++++ src/components/configuration/ConfigPage.tsx | 11 +- src/components/configuration/index.ts | 1 + src/locales/en/translation.json | 22 + src/routeTree.gen.ts | 21 + src/routes/_app/configuration/diff.tsx | 34 ++ src/server/config.ts | 45 ++ src/types/drift.ts | 28 ++ src/types/index.ts | 1 + src/utils/drift.test.ts | 79 ++++ src/utils/drift.ts | 77 ++++ src/utils/index.ts | 1 + 12 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 src/components/configuration/ConfigDiffPage.tsx create mode 100644 src/routes/_app/configuration/diff.tsx create mode 100644 src/types/drift.ts create mode 100644 src/utils/drift.test.ts create mode 100644 src/utils/drift.ts diff --git a/src/components/configuration/ConfigDiffPage.tsx b/src/components/configuration/ConfigDiffPage.tsx new file mode 100644 index 0000000..6a49889 --- /dev/null +++ b/src/components/configuration/ConfigDiffPage.tsx @@ -0,0 +1,425 @@ +import yaml from 'js-yaml'; +import { Link } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Badge, Button, Icon } from '@clickhouse/click-ui'; +import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import type * as t from '@/types'; +import { baseConfigOptions, parseConfigYamlFn } from '@/server'; +import { computeConfigDrift, buildConfiguredValues, cn } from '@/utils'; +import { useLocalize } from '@/hooks'; + +function formatValue(value: t.ConfigValue): string { + if (value === undefined || value === null) return '—'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' || typeof value === 'string') return String(value); + try { + return yaml.dump(value, { lineWidth: -1 }).trimEnd(); + } catch { + return String(value); + } +} + +export function ConfigDiffPage() { + const localize = useLocalize(); + const { data: baseConfigData } = useQuery(baseConfigOptions); + + const [yamlText, setYamlText] = useState(''); + const [fileName, setFileName] = useState(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [validationErrors, setValidationErrors] = useState(); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + + const fileInputRef = useRef(null); + const copyTimer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(copyTimer.current), []); + + const configuredValues = useMemo( + () => + buildConfiguredValues( + baseConfigData?.config ?? null, + baseConfigData?.dbOverrides, + baseConfigData?.configuredFromBase, + ), + [baseConfigData], + ); + + const mergedYaml = useMemo(() => { + if (!result?.merged) return ''; + return yaml.dump(result.merged, { lineWidth: -1, sortKeys: true }).trimEnd(); + }, [result]); + + const resetResult = useCallback(() => { + setError(undefined); + setValidationErrors(undefined); + setResult(null); + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFileName(file.name); + resetResult(); + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result; + if (typeof text === 'string') setYamlText(text); + }; + reader.onerror = () => setError(localize('com_config_import_error')); + reader.readAsText(file); + }; + + const handleCompare = useCallback(async () => { + const trimmed = yamlText.trim(); + if (!trimmed) return; + + setLoading(true); + resetResult(); + + try { + const parsed = await parseConfigYamlFn({ data: { yamlContent: trimmed } }); + if (!parsed.success || !parsed.config) { + setError(parsed.error ?? localize('com_config_import_error')); + if (parsed.validationErrors) { + setValidationErrors(parsed.validationErrors as t.ImportValidationError[]); + } + return; + } + setResult(computeConfigDrift(parsed.config, configuredValues)); + } catch (err) { + setError(err instanceof Error ? err.message : localize('com_config_import_error')); + } finally { + setLoading(false); + } + }, [yamlText, configuredValues, localize, resetResult]); + + const handleCopy = useCallback(async () => { + if (!mergedYaml) return; + try { + await navigator.clipboard.writeText(mergedYaml); + setCopied(true); + clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 2000); + } catch { + setError(localize('com_config_drift_copy_failed')); + } + }, [mergedYaml, localize]); + + const hasContent = yamlText.trim().length > 0; + + return ( +
+
+ + + {localize('com_config_drift_back')} + + +

+ {localize('com_config_drift_title')} +

+

+ {localize('com_config_drift_desc')} +

+ +
+
+
+ + {localize('com_config_drift_input_label')} + + + +
+ +