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')}
+
+
+
+
+
+ );
+}
+
+function ResultPanel({
+ error,
+ validationErrors,
+ result,
+ mergedYaml,
+ copied,
+ onCopy,
+}: {
+ error?: string;
+ validationErrors?: t.ImportValidationError[];
+ result: t.ConfigDriftResult | null;
+ mergedYaml: string;
+ copied: boolean;
+ onCopy: () => void;
+}) {
+ const localize = useLocalize();
+
+ if (error) {
+ return (
+
+
{error}
+ {validationErrors && validationErrors.length > 0 && (
+
+ {validationErrors.slice(0, 20).map((ve, i) => (
+ -
+
{ve.path}: {ve.message}
+
+ ))}
+ {validationErrors.length > 20 && (
+ -
+ {localize('com_config_validation_more', {
+ count: String(validationErrors.length - 20),
+ })}
+
+ )}
+
+ )}
+
+ );
+ }
+
+ if (!result) {
+ return (
+
+
+
+
+
+ {localize('com_config_drift_empty_state')}
+
+
+ );
+ }
+
+ if (result.conflicts.length > 0) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function ConflictView({ conflicts }: { conflicts: t.ConfigDriftConflict[] }) {
+ const localize = useLocalize();
+ const countLabel =
+ conflicts.length === 1
+ ? localize('com_config_drift_conflict_count', { count: conflicts.length })
+ : localize('com_config_drift_conflict_count_plural', { count: conflicts.length });
+
+ return (
+
+
+
+
+
+
+ {countLabel}
+
+
+ {localize('com_config_drift_conflict_desc')}
+
+
+
+
+ {conflicts.map((conflict) => (
+
+
+
+ {conflict.path}
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function MergeView({
+ additions,
+ inSyncCount,
+ mergedYaml,
+ copied,
+ onCopy,
+}: {
+ additions: t.ConfigDriftAddition[];
+ inSyncCount: number;
+ mergedYaml: string;
+ copied: boolean;
+ onCopy: () => void;
+}) {
+ const localize = useLocalize();
+ const driftLabel =
+ additions.length === 1
+ ? localize('com_config_drift_addition_count', { count: additions.length })
+ : localize('com_config_drift_addition_count_plural', { count: additions.length });
+
+ return (
+
+
+
+
+
+
+ {additions.length === 0
+ ? localize('com_config_drift_in_sync')
+ : localize('com_config_drift_ready')}
+
+
+ {inSyncCount > 0 && (
+
+ {localize('com_config_drift_in_sync_count', { count: inSyncCount })}
+
+ )}
+
+
+ {additions.length > 0 && (
+
+
+ {localize('com_config_drift_additions_title')}
+
+
+ {additions.map((addition) => (
+
+
+ {addition.path}
+
+
+ {formatValue(addition.value)}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ {localize('com_config_drift_merged_title')}
+
+
+
+
+ {mergedYaml}
+
+
+
+ );
+}
+
+function ValueRow({
+ label,
+ value,
+ emphasize,
+}: {
+ label: string;
+ value: t.ConfigValue;
+ emphasize?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+
+ {formatValue(value)}
+
+
+ );
+}
diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx
index 085b4a0..452cb5e 100644
--- a/src/components/configuration/ConfigPage.tsx
+++ b/src/components/configuration/ConfigPage.tsx
@@ -1,7 +1,7 @@
import { createPortal } from 'react-dom';
import { Icon } from '@clickhouse/click-ui';
import { PrincipalType } from 'librechat-data-provider';
-import { getRouteApi, useBlocker, useNavigate } from '@tanstack/react-router';
+import { Link, getRouteApi, useBlocker, useNavigate } from '@tanstack/react-router';
import { useState, useMemo, useRef, useCallback, useEffect, startTransition } from 'react';
import { queryOptions, useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import type * as t from '@/types';
@@ -937,6 +937,15 @@ function HeaderActions({
const content = (
<>
+
+
+
+
+ {localize('com_config_drift_nav')}
+
{showImport && (
-
-
+
+
{localize('com_config_drift_input_label')}
@@ -182,9 +260,19 @@ export function ConfigDiffPage() {
error={error}
validationErrors={validationErrors}
result={result}
+ resolutions={resolutions}
+ resolvedCount={resolvedCount}
+ onResolve={handleResolve}
+ onResolveAll={handleResolveAll}
+ merged={merged}
mergedYaml={mergedYaml}
copied={copied}
onCopy={handleCopy}
+ onDownload={handleDownload}
+ canManageConfig={canManageConfig}
+ applying={applying}
+ applyResult={applyResult}
+ onApply={handleApply}
/>
@@ -193,21 +281,43 @@ export function ConfigDiffPage() {
);
}
+interface ResultPanelProps {
+ error?: string;
+ validationErrors?: t.ImportValidationError[];
+ result: t.ConfigDriftResult | null;
+ resolutions: t.ConflictResolutionMap;
+ resolvedCount: number;
+ onResolve: (path: string, side: t.ConflictSide) => void;
+ onResolveAll: (side: t.ConflictSide) => void;
+ merged: Record | null;
+ mergedYaml: string;
+ copied: boolean;
+ onCopy: () => void;
+ onDownload: () => void;
+ canManageConfig: boolean;
+ applying: boolean;
+ applyResult: { ok: boolean; message: string } | null;
+ onApply: () => void;
+}
+
function ResultPanel({
error,
validationErrors,
result,
+ resolutions,
+ resolvedCount,
+ onResolve,
+ onResolveAll,
+ merged,
mergedYaml,
copied,
onCopy,
-}: {
- error?: string;
- validationErrors?: t.ImportValidationError[];
- result: t.ConfigDriftResult | null;
- mergedYaml: string;
- copied: boolean;
- onCopy: () => void;
-}) {
+ onDownload,
+ canManageConfig,
+ applying,
+ applyResult,
+ onApply,
+}: ResultPanelProps) {
const localize = useLocalize();
if (error) {
@@ -250,176 +360,326 @@ function ResultPanel({
);
}
- if (result.conflicts.length > 0) {
- return ;
- }
+ const conflictCount = result.conflicts.length;
return (
-
+
+
+
+ {conflictCount > 0 && (
+
+
+
+ {localize('com_config_drift_conflicts_title')}
+
+
+
+
+
+
+ {result.conflicts.map((conflict) => (
+
+ ))}
+
+ )}
+
+ {result.additions.length > 0 && (
+
+
+ {localize('com_config_drift_additions_title')}
+
+
+ {result.additions.map((addition) => (
+
+
+ {addition.path}
+
+
+ {formatValue(addition.value)}
+
+
+ ))}
+
+
+ )}
+
+ {merged && mergedYaml ? (
+
+ ) : (
+
+ {localize('com_config_drift_resolve_to_merge', {
+ remaining: conflictCount - resolvedCount,
+ })}
+
+ )}
+
);
}
-function ConflictView({ conflicts }: { conflicts: t.ConfigDriftConflict[] }) {
+function StatusBanner({
+ conflictCount,
+ resolvedCount,
+ additionCount,
+ inSyncCount,
+}: {
+ conflictCount: number;
+ resolvedCount: number;
+ additionCount: number;
+ inSyncCount: number;
+}) {
const localize = useLocalize();
- const countLabel =
- conflicts.length === 1
- ? localize('com_config_drift_conflict_count', { count: conflicts.length })
- : localize('com_config_drift_conflict_count_plural', { count: conflicts.length });
+ const allResolved = conflictCount === 0 || resolvedCount === conflictCount;
+ const driftLabel =
+ additionCount === 1
+ ? localize('com_config_drift_addition_count', { count: additionCount })
+ : localize('com_config_drift_addition_count_plural', { count: additionCount });
- return (
-
+ if (!allResolved) {
+ return (
- {countLabel}
+
+ {localize('com_config_drift_conflict_progress', {
+ resolved: resolvedCount,
+ total: conflictCount,
+ })}
+
{localize('com_config_drift_conflict_desc')}
+ );
+ }
-
- {conflicts.map((conflict) => (
-
-
-
- {conflict.path}
-
-
-
-
-
-
-
- ))}
+ return (
+
+
+
+
+
+ {additionCount === 0 && conflictCount === 0
+ ? localize('com_config_drift_in_sync')
+ : localize('com_config_drift_ready')}
+
+
+ {inSyncCount > 0 && (
+
+ {localize('com_config_drift_in_sync_count', { count: inSyncCount })}
+
+ )}
+
+ );
+}
+
+function ConflictCard({
+ conflict,
+ choice,
+ onResolve,
+}: {
+ conflict: t.ConfigDriftConflict;
+ choice?: t.ConflictSide;
+ onResolve: (path: string, side: t.ConflictSide) => void;
+}) {
+ const localize = useLocalize();
+
+ return (
+
+
+
+ {conflict.path}
+
+ {choice && (
+
+
+
+ )}
+
+
+ onResolve(conflict.path, 'yours')}
+ />
+ onResolve(conflict.path, 'librechat')}
+ />
);
}
-function MergeView({
- additions,
- inSyncCount,
+function ConflictOption({
+ label,
+ value,
+ selected,
+ onSelect,
+}: {
+ label: string;
+ value: t.ConfigValue;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+ );
+}
+
+function MergedOutput({
mergedYaml,
copied,
onCopy,
+ onDownload,
+ canManageConfig,
+ applying,
+ applyResult,
+ onApply,
}: {
- additions: t.ConfigDriftAddition[];
- inSyncCount: number;
mergedYaml: string;
copied: boolean;
onCopy: () => void;
+ onDownload: () => void;
+ canManageConfig: boolean;
+ applying: boolean;
+ applyResult: { ok: boolean; message: string } | null;
+ onApply: () => void;
}) {
const localize = useLocalize();
- const driftLabel =
- additions.length === 1
- ? localize('com_config_drift_addition_count', { count: additions.length })
- : localize('com_config_drift_addition_count_plural', { count: additions.length });
return (
-
-
-
-
-
-
- {additions.length === 0
- ? localize('com_config_drift_in_sync')
- : localize('com_config_drift_ready')}
+
+
+
+ {localize('com_config_drift_merged_title')}
-
- {inSyncCount > 0 && (
-
- {localize('com_config_drift_in_sync_count', { count: inSyncCount })}
-
- )}
-
-
- {additions.length > 0 && (
-
-
- {localize('com_config_drift_additions_title')}
-
-
- {additions.map((addition) => (
-
-
- {addition.path}
-
-
- {formatValue(addition.value)}
-
-
- ))}
-
-
- )}
-
-
-
-
- {localize('com_config_drift_merged_title')}
-
+
+
+ {canManageConfig && (
+
+ )}
-
+
+ {applyResult && (
+
- {mergedYaml}
-
-
-
- );
-}
+
+
+
+ {applyResult.message}
+
+ )}
-function ValueRow({
- label,
- value,
- emphasize,
-}: {
- label: string;
- value: t.ConfigValue;
- emphasize?: boolean;
-}) {
- return (
-
-
- {label}
-
-
- {formatValue(value)}
-
+ {mergedYaml}
+
);
}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 210b7bd..8b19df2 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -137,9 +137,12 @@
"com_config_drift_input_label": "Your YAML",
"com_config_drift_compare": "Compare",
"com_config_drift_empty_state": "Paste your YAML and run a comparison to see the drift and the merged result.",
- "com_config_drift_conflict_count": "{{count}} conflict",
- "com_config_drift_conflict_count_plural": "{{count}} conflicts",
- "com_config_drift_conflict_desc": "These fields are set to different values in your YAML and in LibreChat. Resolve each conflict in your YAML, then compare again — nothing is merged while conflicts remain.",
+ "com_config_drift_conflicts_title": "Conflicts",
+ "com_config_drift_conflict_progress": "{{resolved}} of {{total}} conflicts resolved",
+ "com_config_drift_conflict_desc": "These fields are set to different values in your YAML and in LibreChat. Pick which value to keep for each — the merged YAML is generated once every conflict is resolved.",
+ "com_config_drift_keep_all_yours": "Keep all yours",
+ "com_config_drift_keep_all_librechat": "Use all LibreChat",
+ "com_config_drift_resolve_to_merge": "Resolve the remaining {{remaining}} conflict(s) to generate the merged YAML.",
"com_config_drift_your_yaml": "Your YAML",
"com_config_drift_librechat": "LibreChat",
"com_config_drift_ready": "No conflicts — ready to merge",
@@ -152,6 +155,12 @@
"com_config_drift_copy": "Copy",
"com_config_drift_copied": "Copied",
"com_config_drift_copy_failed": "Could not copy to clipboard",
+ "com_config_drift_download": "Download",
+ "com_config_drift_apply": "Apply to LibreChat",
+ "com_config_drift_applying": "Applying...",
+ "com_config_drift_apply_confirm": "Apply this merged configuration to LibreChat? This replaces the base configuration overrides via the admin panel.",
+ "com_config_drift_apply_success": "Merged configuration applied to LibreChat.",
+ "com_config_drift_apply_error": "Failed to apply configuration",
"com_config_tab_files": "Files & storage",
"com_config_tab_endpoints": "Endpoints",
"com_config_tab_custom_endpoints": "Custom endpoints",
diff --git a/src/types/drift.ts b/src/types/drift.ts
index c7a6fa4..54ac75c 100644
--- a/src/types/drift.ts
+++ b/src/types/drift.ts
@@ -15,11 +15,13 @@ export interface ConfigDriftResult {
conflicts: ConfigDriftConflict[];
additions: ConfigDriftAddition[];
inSyncCount: number;
- /** Null whenever there are conflicts — a merged result is only produced
- * once every conflicting field is resolved in the pasted YAML. */
- merged: Record
| null;
}
+/** Which side of a conflict the user chose to keep in the merged result. */
+export type ConflictSide = 'yours' | 'librechat';
+
+export type ConflictResolutionMap = Record;
+
export interface ParseConfigYamlResult {
success: boolean;
error?: string;
diff --git a/src/utils/drift.test.ts b/src/utils/drift.test.ts
index 5fcbdfe..2ffb0e1 100644
--- a/src/utils/drift.test.ts
+++ b/src/utils/drift.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { computeConfigDrift, buildConfiguredValues } from './drift';
+import { computeConfigDrift, buildMergedConfig, buildConfiguredValues } from './drift';
describe('computeConfigDrift', () => {
it('reports configured paths missing from the pasted config as additions', () => {
@@ -13,20 +13,14 @@ describe('computeConfigDrift', () => {
expect(result.additions).toEqual([
{ path: 'registration.allowedDomains', value: ['example.com'] },
]);
- expect(result.merged).toEqual({
- version: '1.2.0',
- cache: true,
- registration: { allowedDomains: ['example.com'] },
- });
});
- it('flags differing values as conflicts and produces no merge', () => {
+ it('flags differing values as conflicts', () => {
const pasted = { 'interface.modelSelect': true };
const configured = { 'interface.modelSelect': false };
const result = computeConfigDrift(pasted, configured);
- expect(result.merged).toBeNull();
expect(result.additions).toEqual([]);
expect(result.conflicts).toEqual([
{ path: 'interface.modelSelect', pastedValue: true, configuredValue: false },
@@ -42,7 +36,6 @@ describe('computeConfigDrift', () => {
expect(result.inSyncCount).toBe(1);
expect(result.conflicts).toEqual([]);
expect(result.additions).toEqual([]);
- expect(result.merged).toEqual({ registration: { allowedDomains: ['a.com', 'b.com'] } });
});
it('sorts conflicts and additions by path', () => {
@@ -52,11 +45,45 @@ describe('computeConfigDrift', () => {
const result = computeConfigDrift(pasted, configured);
expect(result.conflicts.map((c) => c.path)).toEqual(['a.x', 'b.x']);
- expect(result.merged).toBeNull();
expect(result.additions.map((a) => a.path)).toEqual(['a.add', 'z.add']);
});
});
+describe('buildMergedConfig', () => {
+ it('folds additions into the pasted config when there are no conflicts', () => {
+ const pasted = { version: '1.2.0', cache: true };
+ const additions = [{ path: 'registration.allowedDomains', value: ['example.com'] }];
+
+ const merged = buildMergedConfig(pasted, additions, [], {});
+
+ expect(merged).toEqual({
+ version: '1.2.0',
+ cache: true,
+ registration: { allowedDomains: ['example.com'] },
+ });
+ });
+
+ it('returns null while any conflict is unresolved', () => {
+ const conflicts = [
+ { path: 'interface.modelSelect', pastedValue: true, configuredValue: false },
+ ];
+
+ expect(buildMergedConfig({ 'interface.modelSelect': true }, [], conflicts, {})).toBeNull();
+ });
+
+ it('applies the chosen side per conflict', () => {
+ const pasted = { 'a.x': 1, 'b.y': 'mine' };
+ const conflicts = [
+ { path: 'a.x', pastedValue: 1, configuredValue: 2 },
+ { path: 'b.y', pastedValue: 'mine', configuredValue: 'theirs' },
+ ];
+
+ const merged = buildMergedConfig(pasted, [], conflicts, { 'a.x': 'librechat', 'b.y': 'yours' });
+
+ expect(merged).toEqual({ a: { x: 2 }, b: { y: 'mine' } });
+ });
+});
+
describe('buildConfiguredValues', () => {
it('collects configured base paths and merges db overrides with precedence', () => {
const config = { version: '1.0', registration: { socialLogins: ['google'] } };
diff --git a/src/utils/drift.ts b/src/utils/drift.ts
index 67ab483..187577f 100644
--- a/src/utils/drift.ts
+++ b/src/utils/drift.ts
@@ -11,8 +11,7 @@ function valuesEqual(a: t.ConfigValue, b: t.ConfigValue): boolean {
* Compares a pasted config against the values currently configured in
* LibreChat. Configured leaf paths absent from the pasted config become
* additions (the drift); paths present on both sides with differing values
- * become conflicts. A merged result is only produced when there are no
- * conflicts — otherwise the conflicts must be resolved in the pasted YAML.
+ * become conflicts that must be resolved before a merge can be produced.
*/
export function computeConfigDrift(
pasted: Record,
@@ -38,15 +37,30 @@ export function computeConfigDrift(
conflicts.sort((a, b) => a.path.localeCompare(b.path));
additions.sort((a, b) => a.path.localeCompare(b.path));
- if (conflicts.length > 0) {
- return { conflicts, additions, inSyncCount, merged: null };
- }
+ return { conflicts, additions, inSyncCount };
+}
- const mergedFlat: t.FlatConfigMap = { ...flatPasted };
+/**
+ * Builds the merged config from the pasted config, the drift additions and
+ * the per-conflict resolutions. Returns null while any conflict is still
+ * unresolved — a merge is only valid once every conflict has a chosen side.
+ */
+export function buildMergedConfig(
+ pasted: Record,
+ additions: t.ConfigDriftAddition[],
+ conflicts: t.ConfigDriftConflict[],
+ resolutions: t.ConflictResolutionMap,
+): Record | null {
+ const flat = flattenObject(pasted);
for (const addition of additions) {
- mergedFlat[addition.path] = addition.value;
+ flat[addition.path] = addition.value;
+ }
+ for (const conflict of conflicts) {
+ const choice = resolutions[conflict.path];
+ if (!choice) return null;
+ flat[conflict.path] = choice === 'librechat' ? conflict.configuredValue : conflict.pastedValue;
}
- return { conflicts, additions, inSyncCount, merged: unflattenObject(mergedFlat) };
+ return unflattenObject(flat);
}
/**