Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
685 changes: 685 additions & 0 deletions src/components/configuration/ConfigDiffPage.tsx

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion src/components/configuration/ConfigPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -937,6 +937,15 @@ function HeaderActions({

const content = (
<>
<Link
to="/configuration/diff"
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border border-(--cui-color-stroke-default) bg-transparent px-3 py-1.5 text-sm text-(--cui-color-text-default) no-underline transition-colors hover:bg-(--cui-color-background-hover)"
>
<span aria-hidden="true">
<Icon name="document" size="xs" />
</span>
{localize('com_config_drift_nav')}
</Link>
{showImport && (
<button
type="button"
Expand Down
1 change: 1 addition & 0 deletions src/components/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { FieldRenderer, SingleFieldRenderer } from './FieldRenderer';
export { ConfigTabBar } from './ConfigTabBar';
export { ConfigTabContent } from './ConfigTabContent';
export { ConfigPage } from './ConfigPage';
export { ConfigDiffPage } from './ConfigDiffPage';
export { ImportYamlDialog } from './ImportYamlDialog';
export { InfoBanner } from './InfoBanner';
export { getControlType, getEnumOptions, getArrayItemType } from './utils';
Expand Down
31 changes: 31 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,37 @@
"com_config_import_profile_success": "Imported {{count}} profile values for {{name}}",
"com_config_import_creating": "Creating scope...",
"com_config_import_saving": "Saving profile values...",
"com_config_drift_nav": "Compare & merge",
"com_config_drift_title": "Compare & merge YAML",
"com_config_drift_desc": "Paste a librechat.yaml and compare it against the values currently configured in LibreChat. Missing configured values are folded in as drift, and you get a single valid YAML ready to save.",
"com_config_drift_back": "Back to configuration",
"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_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",
"com_config_drift_in_sync": "Already in sync — no drift",
"com_config_drift_addition_count": "{{count}} added from LibreChat",
"com_config_drift_addition_count_plural": "{{count}} added from LibreChat",
"com_config_drift_in_sync_count": "{{count}} already in sync",
"com_config_drift_additions_title": "Drift (added from LibreChat)",
"com_config_drift_merged_title": "Merged YAML",
"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",
Expand Down
21 changes: 21 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Route as AppGrantsRouteImport } from './routes/_app/grants'
import { Route as AppAccessRouteImport } from './routes/_app/access'
import { Route as AppConfigurationIndexRouteImport } from './routes/_app/configuration/index'
import { Route as AuthOpenidCallbackRouteImport } from './routes/auth/openid/callback'
import { Route as AppConfigurationDiffRouteImport } from './routes/_app/configuration/diff'

const LoginRoute = LoginRouteImport.update({
id: '/login',
Expand Down Expand Up @@ -63,6 +64,11 @@ const AuthOpenidCallbackRoute = AuthOpenidCallbackRouteImport.update({
path: '/auth/openid/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AppConfigurationDiffRoute = AppConfigurationDiffRouteImport.update({
id: '/configuration/diff',
path: '/configuration/diff',
getParentRoute: () => AppRoute,
} as any)

export interface FileRoutesByFullPath {
'/': typeof AppIndexRoute
Expand All @@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
'/grants': typeof AppGrantsRoute
'/help': typeof AppHelpRoute
'/users': typeof AppUsersRoute
'/configuration/diff': typeof AppConfigurationDiffRoute
'/auth/openid/callback': typeof AuthOpenidCallbackRoute
'/configuration/': typeof AppConfigurationIndexRoute
}
Expand All @@ -81,6 +88,7 @@ export interface FileRoutesByTo {
'/help': typeof AppHelpRoute
'/users': typeof AppUsersRoute
'/': typeof AppIndexRoute
'/configuration/diff': typeof AppConfigurationDiffRoute
'/auth/openid/callback': typeof AuthOpenidCallbackRoute
'/configuration': typeof AppConfigurationIndexRoute
}
Expand All @@ -93,6 +101,7 @@ export interface FileRoutesById {
'/_app/help': typeof AppHelpRoute
'/_app/users': typeof AppUsersRoute
'/_app/': typeof AppIndexRoute
'/_app/configuration/diff': typeof AppConfigurationDiffRoute
'/auth/openid/callback': typeof AuthOpenidCallbackRoute
'/_app/configuration/': typeof AppConfigurationIndexRoute
}
Expand All @@ -105,6 +114,7 @@ export interface FileRouteTypes {
| '/grants'
| '/help'
| '/users'
| '/configuration/diff'
| '/auth/openid/callback'
| '/configuration/'
fileRoutesByTo: FileRoutesByTo
Expand All @@ -115,6 +125,7 @@ export interface FileRouteTypes {
| '/help'
| '/users'
| '/'
| '/configuration/diff'
| '/auth/openid/callback'
| '/configuration'
id:
Expand All @@ -126,6 +137,7 @@ export interface FileRouteTypes {
| '/_app/help'
| '/_app/users'
| '/_app/'
| '/_app/configuration/diff'
| '/auth/openid/callback'
| '/_app/configuration/'
fileRoutesById: FileRoutesById
Expand Down Expand Up @@ -201,6 +213,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthOpenidCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/configuration/diff': {
id: '/_app/configuration/diff'
path: '/configuration/diff'
fullPath: '/configuration/diff'
preLoaderRoute: typeof AppConfigurationDiffRouteImport
parentRoute: typeof AppRoute
}
}
}

Expand All @@ -210,6 +229,7 @@ interface AppRouteChildren {
AppHelpRoute: typeof AppHelpRoute
AppUsersRoute: typeof AppUsersRoute
AppIndexRoute: typeof AppIndexRoute
AppConfigurationDiffRoute: typeof AppConfigurationDiffRoute
AppConfigurationIndexRoute: typeof AppConfigurationIndexRoute
}

Expand All @@ -219,6 +239,7 @@ const AppRouteChildren: AppRouteChildren = {
AppHelpRoute: AppHelpRoute,
AppUsersRoute: AppUsersRoute,
AppIndexRoute: AppIndexRoute,
AppConfigurationDiffRoute: AppConfigurationDiffRoute,
AppConfigurationIndexRoute: AppConfigurationIndexRoute,
}

Expand Down
34 changes: 34 additions & 0 deletions src/routes/_app/configuration/diff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { AccessDenied, PermissionsUnavailable } from '@/components/shared';
import { ConfigDiffPage } from '@/components/configuration';
import { getConfigSchemaFields } from '@/server';
import { hasConfigCapability } from '@/utils';
import { useCapabilities } from '@/hooks';

export const Route = createFileRoute('/_app/configuration/diff')({
loader: () => getConfigSchemaFields(),
component: ConfigurationDiffRoute,
});

function ConfigurationDiffRoute() {
const { hasCapability, isLoading, isError } = useCapabilities();
const { tree: schemaTree } = Route.useLoaderData();

const canViewAnyConfig = useMemo(
() =>
hasConfigCapability(hasCapability, null, 'read') ||
hasConfigCapability(hasCapability, null, 'manage') ||
schemaTree.some(
(s) =>
hasConfigCapability(hasCapability, s.key, 'read') ||
hasConfigCapability(hasCapability, s.key, 'manage'),
),
[hasCapability, schemaTree],
);

if (isLoading) return null;
if (isError) return <PermissionsUnavailable />;
if (!canViewAnyConfig) return <AccessDenied />;
return <ConfigDiffPage />;
}
45 changes: 45 additions & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,51 @@ export const parseImportedYaml = createServerFn({ method: 'POST' })
}
});

/** Parses and validates a pasted YAML config for the drift comparison tool.
* Returns the *literal* parsed values (not AppService-expanded and without
* Zod defaults injected) so the comparison reflects exactly what the user
* wrote. */
export const parseConfigYamlFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({ yamlContent: z.string() }))
.handler(async ({ data }: { data: { yamlContent: string } }): Promise<t.ParseConfigYamlResult> => {
let rawConfig: unknown;
try {
rawConfig = yaml.load(data.yamlContent, { schema: yaml.JSON_SCHEMA });
} catch (parseError) {
console.error('Failed to parse YAML content for drift comparison:', parseError);
return {
success: false,
error: 'Invalid YAML syntax. Please check the content for syntax errors.',
config: null,
};
}

if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
return {
success: false,
error: 'YAML did not produce a valid configuration object',
config: null,
};
}

const result = configSchema.safeParse(rawConfig);
if (!result.success) {
return {
success: false,
error: 'Config validation failed',
validationErrors: result.error.errors.map(
(e: { path: (string | number)[]; message: string }) => ({
path: e.path.join('.'),
message: e.message,
}),
),
config: null,
};
}

return { success: true, config: rawConfig as Record<string, t.ConfigValue> };
});

function getFieldDefault(schema: t.ZodSchemaLike): { hasDefault: boolean; value: unknown } {
let current = schema;
while (current?._def) {
Expand Down
30 changes: 30 additions & 0 deletions src/types/drift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ConfigValue } from './config';

export interface ConfigDriftConflict {
path: string;
pastedValue: ConfigValue;
configuredValue: ConfigValue;
}

export interface ConfigDriftAddition {
path: string;
value: ConfigValue;
}

export interface ConfigDriftResult {
conflicts: ConfigDriftConflict[];
additions: ConfigDriftAddition[];
inSyncCount: number;
}

/** Which side of a conflict the user chose to keep in the merged result. */
export type ConflictSide = 'yours' | 'librechat';

export type ConflictResolutionMap = Record<string, ConflictSide>;

export interface ParseConfigYamlResult {
success: boolean;
error?: string;
validationErrors?: { path: string; message: string }[];
config: Record<string, ConfigValue> | null;
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type * from './access';
export type * from './auth';
export type * from './config';
export type * from './config-ui';
export type * from './drift';
export type * from './fields';
export type * from './grant';
export type * from './hooks';
Expand Down
Loading