From ebdb7e6b7856238ca90e4d3bd90313301d053ca0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:00:53 +0200 Subject: [PATCH 1/2] fix: Fall Back From an Invalid interface.theme Instead of Exiting A mistake in interface.theme failed the whole librechat.yaml and stopped the server. The loader now checks the theme with the rules the client applies before painting it, drops a theme the client would reject with a warning naming each problem by path, and loads the rest of the config. Unknown appearance tokens only warn, matching the reader. Startup and reload behave the same. The token lists and the validator move to librechat-data-provider so the client registry and the server check against one list. --- .../scenarios/yaml-theme-fallback.spec.ts | 118 +++++ .../src/app/__fixtures__/theme/bad-value.yaml | 13 + .../src/app/__fixtures__/theme/malformed.yaml | 7 + .../src/app/__fixtures__/theme/spacing.yaml | 11 + .../theme/unknown-appearance.yaml | 12 + .../app/__fixtures__/theme/unknown-token.yaml | 13 + .../__fixtures__/theme/unrelated-error.yaml | 12 + .../api/src/app/__fixtures__/theme/valid.yaml | 18 + packages/api/src/app/loader.spec.ts | 147 ++++++ packages/api/src/app/loader.ts | 20 + packages/api/src/app/theme.ts | 65 +++ packages/client/src/theme/registry.ts | 332 ++------------ packages/data-provider/src/index.ts | 1 + packages/data-provider/src/theme.ts | 434 ++++++++++++++++++ 14 files changed, 907 insertions(+), 296 deletions(-) create mode 100644 e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts create mode 100644 packages/api/src/app/__fixtures__/theme/bad-value.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/malformed.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/spacing.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/unknown-token.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/unrelated-error.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/valid.yaml create mode 100644 packages/api/src/app/loader.spec.ts create mode 100644 packages/api/src/app/theme.ts create mode 100644 packages/data-provider/src/theme.ts diff --git a/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts b/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts new file mode 100644 index 00000000000..83082816be8 --- /dev/null +++ b/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts @@ -0,0 +1,118 @@ +import { resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { inOneProject, repoRoot, run } from './lint.helpers'; + +/** + * A mistake in `interface.theme` used to fail the whole librechat.yaml and stop the server. The + * config loader that `/api` wires up now drops only the theme, names each problem by its path, and + * loads everything else. The mock lane serves one shared yaml, so these load their own fixtures + * through that same loader in a child process, the way the server does at startup and on reload. + */ +test.describe.configure({ timeout: 120_000 }); + +const fixtures = resolve(repoRoot, 'packages/api/src/app/__fixtures__/theme'); +const RESULT = '__THEME_SCENARIO_RESULT__'; +/** The logger colours its console output. */ +const ANSI_COLOR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +const loadScript = ` +const load = require('./server/services/Config/loadCustomConfig'); +process.exit = (code) => { + console.log('${RESULT}' + JSON.stringify({ exited: code })); + process.reallyExit(0); +}; +load(false, { mode: process.argv[1] }).then( + (config) => { + console.log('${RESULT}' + JSON.stringify({ interface: config && config.interface })); + process.reallyExit(0); + }, + (error) => { + console.log('${RESULT}' + JSON.stringify({ rejected: error.name })); + process.reallyExit(0); + }, +); +`; + +type LoadOutcome = { + interface?: { theme?: unknown; modelSelect?: boolean }; + exited?: number; + rejected?: string; +}; + +function loadFixture(name: string, mode: 'startup' | 'reload' = 'startup') { + process.env.CONFIG_PATH = resolve(fixtures, `${name}.yaml`); + const result = run('node', ['-e', loadScript, mode], { cwd: resolve(repoRoot, 'api') }); + const log = result.output.replace(ANSI_COLOR, ''); + const line = log.split('\n').find((entry) => entry.startsWith(RESULT)); + if (!line) { + throw new Error(`The loader printed no result:\n${log}`); + } + return { outcome: JSON.parse(line.slice(RESULT.length)) as LoadOutcome, log }; +} + +test.describe('interface.theme in librechat.yaml', () => { + test.beforeEach(() => inOneProject()); + test.afterEach(() => { + delete process.env.CONFIG_PATH; + }); + + test('a typo in a theme token leaves the server running on the default theme @scenario:yaml-theme-typo-falls-back', () => { + const { outcome, log } = loadFixture('unknown-token'); + + expect(outcome.exited).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain('the default theme applies instead'); + expect(log).toContain( + 'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary', + ); + expect(log).toContain( + 'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary', + ); + }); + + test('a bad token value is reported with its path and the theme is dropped @scenario:yaml-theme-bad-value-reported', () => { + const { outcome, log } = loadFixture('bad-value'); + + expect(outcome.exited).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain( + 'interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value for rgb-surface-primary: 300 16 32', + ); + expect(log).toContain( + 'interface.theme.modes.dark.appearance.controlRadius: Invalid appearance value for controlRadius: 4 pixels', + ); + }); + + test('a valid inline theme loads exactly as written @scenario:yaml-theme-valid-unchanged', () => { + const { outcome, log } = loadFixture('valid'); + + expect(outcome.interface?.theme).toEqual({ + version: 1, + name: 'acme', + modes: { + light: { + colors: { 'rgb-surface-primary': '240 244 255' }, + appearance: { controlRadius: '0.25rem' }, + }, + dark: { colors: { 'rgb-surface-primary': '12 16 32' } }, + }, + brands: { 'provider-openai': '#10a37f' }, + }); + expect(log).not.toContain('interface.theme'); + }); + + test('a config reload with a broken theme applies the same fallback @scenario:yaml-theme-reload-falls-back', () => { + const { outcome, log } = loadFixture('unknown-token', 'reload'); + + expect(outcome.rejected).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain('interface.theme.modes.light.colors.rgb-surfce-secondary'); + }); + + test('an invalid key outside the theme still stops startup @scenario:yaml-theme-other-errors-still-exit', () => { + const { outcome } = loadFixture('unrelated-error'); + + expect(outcome.exited).toBe(1); + expect(loadFixture('unrelated-error', 'reload').outcome.rejected).toBe('ConfigReloadError'); + }); +}); diff --git a/packages/api/src/app/__fixtures__/theme/bad-value.yaml b/packages/api/src/app/__fixtures__/theme/bad-value.yaml new file mode 100644 index 00000000000..1dd4e4704e0 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/bad-value.yaml @@ -0,0 +1,13 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + dark: + colors: + rgb-surface-primary: '300 16 32' + appearance: + controlRadius: 4 pixels diff --git a/packages/api/src/app/__fixtures__/theme/malformed.yaml b/packages/api/src/app/__fixtures__/theme/malformed.yaml new file mode 100644 index 00000000000..e1ac0b825e0 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/malformed.yaml @@ -0,0 +1,7 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + - acme + - clickhouse diff --git a/packages/api/src/app/__fixtures__/theme/spacing.yaml b/packages/api/src/app/__fixtures__/theme/spacing.yaml new file mode 100644 index 00000000000..9f8da1036ff --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/spacing.yaml @@ -0,0 +1,11 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surface-primary: '240 244 255' diff --git a/packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml b/packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml new file mode 100644 index 00000000000..690277e45e0 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unknown-appearance.yaml @@ -0,0 +1,12 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + appearance: + controlRadius: 2px + futureSpacing: 3rem diff --git a/packages/api/src/app/__fixtures__/theme/unknown-token.yaml b/packages/api/src/app/__fixtures__/theme/unknown-token.yaml new file mode 100644 index 00000000000..c8cdec00ba2 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unknown-token.yaml @@ -0,0 +1,13 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surface-primary: '240 244 255' + rgb-surfce-secondary: '250 250 250' + surface-tertiary: '245 245 245' diff --git a/packages/api/src/app/__fixtures__/theme/unrelated-error.yaml b/packages/api/src/app/__fixtures__/theme/unrelated-error.yaml new file mode 100644 index 00000000000..353ecee5477 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unrelated-error.yaml @@ -0,0 +1,12 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surfce-primary: '240 244 255' +notAConfigKey: true diff --git a/packages/api/src/app/__fixtures__/theme/valid.yaml b/packages/api/src/app/__fixtures__/theme/valid.yaml new file mode 100644 index 00000000000..e01463685ab --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/valid.yaml @@ -0,0 +1,18 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: + version: 1 + name: acme + modes: + light: + colors: + rgb-surface-primary: '240 244 255' + appearance: + controlRadius: 0.25rem + dark: + colors: + rgb-surface-primary: '12 16 32' + brands: + provider-openai: '#10a37f' diff --git a/packages/api/src/app/loader.spec.ts b/packages/api/src/app/loader.spec.ts new file mode 100644 index 00000000000..4ad40ded1eb --- /dev/null +++ b/packages/api/src/app/loader.spec.ts @@ -0,0 +1,147 @@ +import path from 'path'; +import { logger } from '@librechat/data-schemas'; +import type { TCustomConfig } from 'librechat-data-provider'; +import { createCustomConfigLoader, ConfigReloadError } from './loader'; +import { loadYaml } from '~/utils/yaml'; + +const fixture = (name: string) => path.join(__dirname, '__fixtures__', 'theme', `${name}.yaml`); + +const raw = (name: string) => loadYaml(fixture(name)) as TCustomConfig; + +const createLoader = () => + createCustomConfigLoader({ + loadLocal: loadYaml, + defaultConfigPath: fixture('valid'), + redactConfig: (config: TCustomConfig) => config, + }); + +const warnings = (spy: jest.SpyInstance): string => + spy.mock.calls.map(([message]) => String(message)).join('\n'); + +describe('createCustomConfigLoader interface.theme', () => { + const originalConfigPath = process.env.CONFIG_PATH; + let warn: jest.SpyInstance; + let exit: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(logger, 'warn'); + jest.spyOn(logger, 'error').mockImplementation(() => logger); + jest.spyOn(logger, 'info').mockImplementation(() => logger); + exit = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (originalConfigPath === undefined) { + delete process.env.CONFIG_PATH; + } else { + process.env.CONFIG_PATH = originalConfigPath; + } + }); + + const load = async (name: string, mode: 'startup' | 'reload' = 'startup') => { + process.env.CONFIG_PATH = fixture(name); + return createLoader()(false, { mode }); + }; + + it('returns a valid theme exactly as written, without warnings', async () => { + const config = await load('valid'); + + expect(config?.interface?.theme).toEqual(raw('valid').interface?.theme); + expect(config?.interface?.modelSelect).toBe(true); + expect(warn).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + }); + + it('drops a theme naming an unknown color token and keeps the rest of the config', async () => { + const config = await load('unknown-token'); + + expect(exit).not.toHaveBeenCalled(); + expect(config).not.toBeNull(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(config?.cache).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token: rgb-surfce-secondary', + ); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.colors.surface-tertiary: Unknown color token: surface-tertiary', + ); + expect(warnings(warn)).toContain('the default theme applies instead'); + }); + + it('reports every bad value with its path and drops the theme', async () => { + const config = await load('bad-value'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + const logged = warnings(warn); + expect(logged).toContain( + 'interface.theme.modes.dark.colors.rgb-surface-primary: Invalid RGB value for rgb-surface-primary: 300 16 32', + ); + expect(logged).toContain( + 'interface.theme.modes.dark.appearance.controlRadius: Invalid appearance value for controlRadius: 4 pixels', + ); + }); + + it('drops a theme that is neither a name nor a definition', async () => { + const config = await load('malformed'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme: Expected a bundled theme name or an inline theme definition', + ); + }); + + it('drops a value the client reads but the config schema rejects, citing the schema path', async () => { + const config = await load('spacing'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(warnings(warn)).toContain('interface.theme.modes.light.colors.rgb-surface-primary:'); + }); + + it('keeps a theme whose only problem is an appearance token this version ignores', async () => { + const config = await load('unknown-appearance'); + + expect(config?.interface?.theme).toEqual(raw('unknown-appearance').interface?.theme); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.appearance.futureSpacing: Unknown light appearance token ignored: futureSpacing', + ); + expect(warnings(warn)).not.toContain('the default theme applies instead'); + }); + + it('still exits at startup when something other than the theme is invalid', async () => { + await expect(load('unrelated-error')).rejects.toThrow('process.exit(1)'); + expect(exit).toHaveBeenCalledWith(1); + expect(warnings(warn)).toContain('interface.theme.modes.light.colors.rgb-surfce-primary'); + }); + + describe('reload mode', () => { + it('applies the same fallback instead of rejecting the reload', async () => { + const config = await load('unknown-token', 'reload'); + + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme.modes.light.colors.rgb-surfce-secondary: Unknown color token', + ); + }); + + it('returns a valid theme unchanged', async () => { + const config = await load('valid', 'reload'); + + expect(config?.interface?.theme).toEqual(raw('valid').interface?.theme); + expect(warn).not.toHaveBeenCalled(); + }); + + it('still rejects a reload whose other keys are invalid', async () => { + await expect(load('unrelated-error', 'reload')).rejects.toBeInstanceOf(ConfigReloadError); + expect(exit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/app/loader.ts b/packages/api/src/app/loader.ts index 6365450c4ea..25cdad6cdc3 100644 --- a/packages/api/src/app/loader.ts +++ b/packages/api/src/app/loader.ts @@ -14,6 +14,7 @@ import { } from 'librechat-data-provider'; import type { TCustomConfig, TEndpoint } from 'librechat-data-provider'; import type { ZodIssue } from 'zod'; +import { checkConfigTheme } from './theme'; type CustomParams = NonNullable; type CustomParamDefinition = NonNullable[number]; @@ -154,6 +155,21 @@ function isRemoteConfigPath(configPath: string): boolean { return /^https?:\/\//.test(configPath); } +function reportThemeCheck(configPath: string, errors: string[], warnings: string[]): void { + if (errors.length > 0) { + logger.warn( + `Ignoring interface.theme in ${configPath}; the default theme applies instead:\n` + + errors.map((error) => `- ${error}`).join('\n'), + ); + } + if (warnings.length > 0) { + logger.warn( + `interface.theme in ${configPath} names tokens this version ignores:\n` + + warnings.map((warning) => `- ${warning}`).join('\n'), + ); + } +} + /** Creates one process-local custom-config reader. */ export function createCustomConfigLoader({ defaultConfigPath, @@ -242,6 +258,10 @@ export function createCustomConfigLoader({ } } + const themeCheck = checkConfigTheme(loadedConfig); + loadedConfig = themeCheck.config; + reportThemeCheck(configPath, themeCheck.errors, themeCheck.warnings); + setMaxSubagents(getConfiguredMaxSubagents(loadedConfig)); const result = configSchema.strict().safeParse(loadedConfig); if ( diff --git a/packages/api/src/app/theme.ts b/packages/api/src/app/theme.ts new file mode 100644 index 00000000000..09ab928333e --- /dev/null +++ b/packages/api/src/app/theme.ts @@ -0,0 +1,65 @@ +import { + collectThemeIssues, + isPlainThemeRecord, + deploymentThemeSchema, + collectThemeWarningIssues, +} from 'librechat-data-provider'; +import type { ThemeIssue } from 'librechat-data-provider'; + +const THEME_PATH = ['interface', 'theme']; + +export interface ConfigThemeCheck { + /** The config to validate and return: the input itself unless the theme had to be dropped. */ + config: unknown; + /** Why the theme was dropped, one `path: reason` line per problem. */ + errors: string[]; + /** Tokens the client will ignore while still applying the rest of the theme. */ + warnings: string[]; +} + +const formatIssue = ({ path, message }: ThemeIssue): string => + `${[...THEME_PATH, ...path].join('.')}: ${message}`; + +function collectErrors(theme: unknown): ThemeIssue[] { + if (typeof theme !== 'string' && !isPlainThemeRecord(theme)) { + return [{ path: [], message: 'Expected a bundled theme name or an inline theme definition' }]; + } + const issues = typeof theme === 'string' ? [] : collectThemeIssues(theme); + if (issues.length > 0) { + return issues; + } + const result = deploymentThemeSchema.safeParse(theme); + if (result.success) { + return []; + } + return result.error.errors.map(({ path, message }) => ({ + path: path.map(String), + message, + })); +} + +/** + * Checks `interface.theme` with the rules the client applies before painting it. A theme the + * client would reject is removed, so the deployment falls back to the default theme and the rest + * of the config still loads; unknown appearance tokens, which the client drops on its own, only + * warn. A config without a theme, or with a valid one, is returned as the same object. + */ +export function checkConfigTheme(config: unknown): ConfigThemeCheck { + const unchanged: ConfigThemeCheck = { config, errors: [], warnings: [] }; + if (!isPlainThemeRecord(config) || !isPlainThemeRecord(config.interface)) { + return unchanged; + } + const interfaceConfig = config.interface; + if (!('theme' in interfaceConfig) || interfaceConfig.theme === undefined) { + return unchanged; + } + + const theme = interfaceConfig.theme; + const errors = collectErrors(theme).map(formatIssue); + if (errors.length === 0) { + return { config, errors, warnings: collectThemeWarningIssues(theme).map(formatIssue) }; + } + + const { theme: _dropped, ...rest } = interfaceConfig; + return { config: { ...config, interface: rest }, errors, warnings: [] }; +} diff --git a/packages/client/src/theme/registry.ts b/packages/client/src/theme/registry.ts index 3c8fac068e4..5a456097234 100644 --- a/packages/client/src/theme/registry.ts +++ b/packages/client/src/theme/registry.ts @@ -1,3 +1,17 @@ +import { + THEME_VERSION, + isThemeRGB, + collectThemeIssues, + isThemeAppearanceToken, + collectThemeWarningIssues, + themeColorTokens as sharedColorTokens, + themeBrandTokens as sharedBrandTokens, +} from 'librechat-data-provider'; +import type { + ThemeColorToken, + ThemeBrandToken, + ThemeAppearanceToken, +} from 'librechat-data-provider'; import type { IThemeAppearance, IThemeBrands, @@ -11,7 +25,8 @@ import type { import { highContrastDarkTheme, highContrastLightTheme } from './themes/highContrast'; import { defaultTheme } from './themes/default'; import { darkTheme } from './themes/dark'; -export const THEME_VERSION = 1 as const; + +export { THEME_VERSION }; /** * Compile-time guard: the categorical series scale is declared across three @@ -28,9 +43,21 @@ export type SeriesTokensAreDeclared = [ Assert>, ]; -export const themeColorTokens: readonly (keyof IThemeRGB)[] = Object.freeze( - Object.keys(defaultTheme) as Array, -); +type SameKeys = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; + +/** The token lists live in `librechat-data-provider` so the server validates against the same + * set; these fail the build when the client types drift from them. */ +export type SharedTokensMatchTypes = [ + Assert>, + Assert>, + Assert>, +]; + +export const themeColorTokens: readonly (keyof IThemeRGB)[] = sharedColorTokens; /** * What the verified mark is measured against: the fill it wore before it had a @@ -120,15 +147,7 @@ export const defaultAppearance: IThemeAppearance = Object.freeze({ motionNormal: '200ms', }); -export const themeBrandTokens: readonly (keyof IThemeBrands)[] = Object.freeze([ - 'provider-openai', - 'provider-openai-gpt4', - 'provider-openai-reasoning', - 'provider-anthropic', - 'provider-azure', - 'provider-bedrock', - 'provider-foreground', -]); +export const themeBrandTokens: readonly (keyof IThemeBrands)[] = sharedBrandTokens; export const defaultBrands: IThemeBrands = Object.freeze({ 'provider-openai': '#19C37D', @@ -195,295 +214,16 @@ export const highContrastTheme: ThemeDefinition = Object.freeze({ }, }); -const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/; -const cssLengthPattern = /^(0|\d*\.?\d+(px|rem|em))$/; -const cssLengthDifferencePattern = - /^calc\(\s*\d*\.?\d+(px|rem|em)\s+[-+]\s+\d*\.?\d+(px|rem|em)\s*\)$/; -const cssDurationPattern = /^\d*\.?\d+(ms|s)$/; -const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; -const shadowLengthPattern = /^(-?(0|\d*\.?\d+[a-z]+)|(calc|min|max|clamp)\(.*\))$/i; -const shadowColorPattern = /^(#[0-9a-f]{3,8}|[a-z]+|[a-z-]+\(.*\))$/i; /** Tailwind composes `--tw-shadow` into one list with the ring layers, where `none` is invalid. */ const disabledShadow = '0 0 #0000'; -function isLinearGradient(value: string): boolean { - if (!value.startsWith('linear-gradient(') || /url\s*\(|image-set/i.test(value)) { - return false; - } - let depth = 0; - for (let i = 0; i < value.length; i++) { - const char = value[i]; - if (char === '(') { - depth += 1; - } else if (char === ')') { - depth -= 1; - if (depth === 0) { - return i === value.length - 1; - } - if (depth < 0) { - return false; - } - } - } - return false; -} - -const isRGB = (value: unknown): value is string => { - if (typeof value !== 'string') { - return false; - } - const match = value.match(rgbPattern); - return match !== null && match.slice(1).every((channel) => Number(channel) <= 255); -}; - -/** The bare form, or one `calc()` of two unit-bearing lengths (a bare `0` is a number there): - * the small radius defaults keep a px offset. */ -const isLength = (value: unknown): value is string => - typeof value === 'string' && - (cssLengthPattern.test(value) || cssLengthDifferencePattern.test(value)); -const isFontFamily = (value: unknown): value is string => - typeof value === 'string' && value.trim().length > 0 && !/[;{}]/.test(value); -/** Splits on `separator` outside parentheses, so `rgb(0, 0, 0)` stays one part. Empty parts are - * kept, so a stray comma stays visible to the caller. */ -function splitTopLevel(value: string, separator: RegExp): string[] { - const parts: string[] = []; - let depth = 0; - let current = ''; - for (const char of value) { - if (char === '(') { - depth += 1; - } else if (char === ')') { - depth -= 1; - } - if (depth === 0 && separator.test(char)) { - parts.push(current); - current = ''; - continue; - } - current += char; - } - parts.push(current); - return parts.map((part) => part.trim()); -} - -/** A named color is indistinguishable from any other word without the browser's color parser. */ -const isShadowColor = (token: string): boolean => - globalThis.CSS?.supports?.('color', token) ?? shadowColorPattern.test(token); - -/** One layer: two to four lengths, optionally `inset` and one color, per the box-shadow grammar. */ -function isShadowLayer(layer: string): boolean { - const tokens = splitTopLevel(layer, /\s/).filter((token) => token.length > 0); - const lengths = tokens.filter((token) => shadowLengthPattern.test(token)).length; - const insets = tokens.filter((token) => token.toLowerCase() === 'inset').length; - const colors = tokens.filter( - (token) => !shadowLengthPattern.test(token) && token.toLowerCase() !== 'inset', - ); - return ( - lengths >= 2 && lengths <= 4 && insets <= 1 && colors.length <= 1 && colors.every(isShadowColor) - ); -} - -/** - * A shadow must be concrete: a browser defers its check of any value holding `var()`, `env()` or - * `attr()` until substitution, so such a value could never be validated before it reaches the - * ring layers. - */ -const isShadow = (value: unknown): value is string => { - if (typeof value !== 'string' || /[;{}]|url\s*\(|(var|env|attr)\s*\(/i.test(value)) { - return false; - } - if (value.trim().toLowerCase() === 'none') { - return true; - } - const layers = splitTopLevel(value, /,/); - if (layers.some((layer) => layer.length === 0) || !layers.every(isShadowLayer)) { - return false; - } - return globalThis.CSS?.supports?.('box-shadow', value) ?? true; -}; -const isDuration = (value: unknown): value is string => - typeof value === 'string' && cssDurationPattern.test(value); - -const isPlainRecord = (value: unknown): value is Record => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - try { - const prototype = Object.getPrototypeOf(value); - return prototype === null || prototype.constructor?.name === 'Object'; - } catch { - return false; - } -}; - -const appearanceValidators: Record boolean> = { - controlRadius: isLength, - roundControlRadius: isLength, - surfaceRadius: isLength, - largeSurfaceRadius: isLength, - radiusSm: isLength, - radiusMd: isLength, - radiusLg: isLength, - radiusXl: isLength, - radius2xl: isLength, - radius3xl: isLength, - controlHeight: isLength, - spaceCompact: isLength, - spaceNormal: isLength, - fontFamily: isFontFamily, - monoFontFamily: isFontFamily, - /** Released themes may hold `var()` here, so this role keeps its original, looser check. */ - elevationSurface: (value) => - typeof value === 'string' && value.trim().length > 0 && !/[;{}]|url\s*\(/i.test(value), - shadow2xs: isShadow, - shadowXs: isShadow, - shadowSm: isShadow, - shadowMd: isShadow, - shadowLg: isShadow, - shadowXl: isShadow, - shadow2xl: isShadow, - motionFast: isDuration, - motionNormal: isDuration, -}; - -const isAppearanceKey = (key: string): key is keyof IThemeAppearance => - Object.prototype.hasOwnProperty.call(appearanceValidators, key); - -/** - * A token added after this reader shipped is ignored rather than rejected, so a newer definition - * degrades to the defaults for what this version cannot paint instead of losing every value it - * can. It never reaches the DOM, but it must still look like a token: a camelCase name and a - * plain CSS value, never a declaration or rule break. - */ -const isFutureAppearance = (key: string, value: unknown): boolean => - /^[a-z][a-zA-Z0-9]*$/.test(key) && typeof value === 'string' && !/[;{}<>]|url\s*\(/i.test(value); - /** The appearance tokens this reader does not know, which `resolveTheme` leaves out. */ export function collectThemeWarnings(theme: ThemeDefinition): string[] { - if (!isPlainRecord(theme) || !isPlainRecord(theme.modes)) { - return []; - } - return (['light', 'dark'] as const).flatMap((mode) => { - const appearance: unknown = isPlainRecord(theme.modes[mode]) - ? theme.modes[mode]?.appearance - : undefined; - if (!isPlainRecord(appearance)) { - return []; - } - return Object.keys(appearance) - .filter((key) => !isAppearanceKey(key)) - .map((key) => `Unknown ${mode} appearance token ignored: ${key}`); - }); -} - -/** Shared by the theme-wide `brands` and each mode's override block. */ -function collectBrandErrors(brands: unknown): string[] { - if (!isPlainRecord(brands)) { - return []; - } - - return Object.entries(brands).flatMap(([key, value]) => { - if (!themeBrandTokens.includes(key as keyof IThemeBrands)) { - return [`Unknown brand token: ${key}`]; - } - /** Only the glyph is a flat colour; a fill may also be a gradient. */ - const isColorOnly = key === 'provider-foreground'; - const isValidBrand = - typeof value === 'string' && - (isColorOnly - ? hexColorPattern.test(value) - : hexColorPattern.test(value) || isLinearGradient(value)); - return value !== undefined && !isValidBrand ? [`Invalid brand value for ${key}: ${value}`] : []; - }); + return collectThemeWarningIssues(theme).map(({ message }) => message); } export function validateThemeDefinition(theme: ThemeDefinition): string[] { - const errors: string[] = []; - - if (!isPlainRecord(theme)) { - return ['Theme definition must be an object']; - } - - Object.keys(theme).forEach((key) => { - if (key !== 'version' && key !== 'name' && key !== 'modes' && key !== 'brands') { - errors.push(`Unknown theme field: ${key}`); - } - }); - - if (theme.version !== THEME_VERSION) { - errors.push(`Unsupported theme version: ${theme.version}`); - } - if (typeof theme.name !== 'string' || !theme.name.trim()) { - errors.push('Theme name is required'); - } - if (!isPlainRecord(theme.modes)) { - errors.push('Theme modes must be an object'); - return errors; - } - - Object.keys(theme.modes).forEach((mode) => { - if (mode !== 'light' && mode !== 'dark') { - errors.push(`Unknown theme mode: ${mode}`); - } - }); - - (['light', 'dark'] as const).forEach((mode) => { - const definition = theme.modes[mode]; - if (definition === undefined) { - return; - } - - if (!isPlainRecord(definition)) { - errors.push(`Theme mode ${mode} must be an object`); - return; - } - - Object.keys(definition).forEach((key) => { - if (key !== 'colors' && key !== 'appearance' && key !== 'brands') { - errors.push(`Unknown ${mode} theme field: ${key}`); - } - }); - - if (definition.colors !== undefined && !isPlainRecord(definition.colors)) { - errors.push(`Theme colors for ${mode} must be an object`); - } else { - Object.entries(definition.colors ?? {}).forEach(([key, value]) => { - if (!themeColorTokens.includes(key as keyof IThemeRGB)) { - errors.push(`Unknown color token: ${key}`); - return; - } - if (value !== undefined && !isRGB(value)) { - errors.push(`Invalid RGB value for ${key}: ${value}`); - } - }); - } - - if (definition.appearance !== undefined && !isPlainRecord(definition.appearance)) { - errors.push(`Theme appearance for ${mode} must be an object`); - } else { - Object.entries(definition.appearance ?? {}).forEach(([key, value]) => { - const isKnown = isAppearanceKey(key); - const isValid = isKnown ? appearanceValidators[key](value) : isFutureAppearance(key, value); - if (value !== undefined && !isValid) { - errors.push(`Invalid appearance value for ${key}: ${value}`); - } - }); - } - - if (definition.brands !== undefined && !isPlainRecord(definition.brands)) { - errors.push(`Theme brands for ${mode} must be an object`); - } else { - errors.push(...collectBrandErrors(definition.brands)); - } - }); - - if (theme.brands !== undefined && !isPlainRecord(theme.brands)) { - errors.push('Theme brands must be an object'); - } else { - errors.push(...collectBrandErrors(theme.brands)); - } - - return errors; + return collectThemeIssues(theme).map(({ message }) => message); } /** @@ -503,7 +243,7 @@ function definedEntries(values?: Partial): Partial { function knownAppearance(appearance?: Partial): Partial { return Object.fromEntries( - Object.entries(definedEntries(appearance)).filter(([key]) => isAppearanceKey(key)), + Object.entries(definedEntries(appearance)).filter(([key]) => isThemeAppearanceToken(key)), ); } @@ -679,7 +419,7 @@ export function fromLegacyTheme(colors: IThemeRGB, name = 'custom'): ThemeDefini const legacyName = name.trim() || 'custom'; const sanitizedColors = themeColorTokens.reduce((result, token) => { const value = colors[token]; - if (isRGB(value)) { + if (isThemeRGB(value)) { result[token] = value; } return result; diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 9987174975d..c480ad92b76 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -4,6 +4,7 @@ export * from './bedrock'; export * from './balance'; export * from './config'; export * from './footer'; +export * from './theme'; export * from './langchain'; export * from './filters'; export * from './file-config'; diff --git a/packages/data-provider/src/theme.ts b/packages/data-provider/src/theme.ts new file mode 100644 index 00000000000..73ad7e163bb --- /dev/null +++ b/packages/data-provider/src/theme.ts @@ -0,0 +1,434 @@ +/** + * The theme token contract shared by the client registry, which paints a definition, and the + * server, which checks `interface.theme` when librechat.yaml loads. Both read these lists, so a + * token added here is accepted everywhere at once. + */ +export const THEME_VERSION = 1 as const; + +export const themeColorTokens = Object.freeze([ + 'rgb-text-primary', + 'rgb-text-secondary', + 'rgb-text-secondary-alt', + 'rgb-text-tertiary', + 'rgb-text-muted', + 'rgb-text-warning', + 'rgb-text-destructive', + 'rgb-shimmer-base', + 'rgb-shimmer-dip', + 'rgb-link', + 'rgb-link-hover', + 'rgb-link-visited', + 'rgb-accent-primary', + 'rgb-accent-primary-hover', + 'rgb-ring-primary', + 'rgb-header-primary', + 'rgb-header-hover', + 'rgb-header-button-hover', + 'rgb-surface-active', + 'rgb-surface-active-alt', + 'rgb-surface-hover', + 'rgb-surface-hover-alt', + 'rgb-surface-composer-hover', + 'rgb-surface-primary', + 'rgb-chart-widget-surface', + 'rgb-chart-widget-stroke', + 'rgb-surface-primary-alt', + 'rgb-surface-primary-contrast', + 'rgb-surface-secondary', + 'rgb-surface-secondary-alt', + 'rgb-surface-tertiary', + 'rgb-surface-tertiary-alt', + 'rgb-surface-dialog', + 'rgb-surface-overlay', + 'rgb-surface-submit', + 'rgb-surface-submit-hover', + 'rgb-surface-destructive', + 'rgb-surface-destructive-hover', + 'rgb-surface-chat', + 'rgb-surface-code', + 'rgb-surface-code-body', + 'rgb-surface-inverted', + 'rgb-surface-inverted-hover', + 'rgb-text-inverted', + 'rgb-surface-fixed', + 'rgb-surface-fixed-hover', + 'rgb-text-fixed', + 'rgb-border-light', + 'rgb-border-medium', + 'rgb-border-medium-alt', + 'rgb-border-heavy', + 'rgb-border-xheavy', + 'rgb-border-destructive', + 'rgb-border-control', + 'rgb-status-success', + 'rgb-status-success-subtle', + 'rgb-status-success-border', + 'rgb-status-success-strong', + 'rgb-status-info', + 'rgb-status-info-subtle', + 'rgb-status-info-border', + 'rgb-status-info-strong', + 'rgb-status-warning', + 'rgb-status-warning-subtle', + 'rgb-status-warning-border', + 'rgb-status-warning-strong', + 'rgb-status-error', + 'rgb-status-error-subtle', + 'rgb-status-error-border', + 'rgb-status-error-strong', + 'rgb-status-neutral', + 'rgb-status-neutral-subtle', + 'rgb-status-neutral-border', + 'rgb-status-verified', + 'rgb-text-on-status', + 'rgb-brand-purple', + 'rgb-syntax-text', + 'rgb-syntax-comment', + 'rgb-syntax-meta', + 'rgb-syntax-builtin', + 'rgb-syntax-keyword', + 'rgb-syntax-string', + 'rgb-syntax-attr', + 'rgb-syntax-title', + 'rgb-series-1', + 'rgb-series-2', + 'rgb-series-3', + 'rgb-series-4', + 'rgb-series-5', + 'rgb-series-6', + 'rgb-series-7', + 'rgb-series-8', + 'rgb-switch-unchecked', + 'rgb-presentation', +] as const); + +export type ThemeColorToken = (typeof themeColorTokens)[number]; + +export const themeBrandTokens = Object.freeze([ + 'provider-openai', + 'provider-openai-gpt4', + 'provider-openai-reasoning', + 'provider-anthropic', + 'provider-azure', + 'provider-bedrock', + 'provider-foreground', +] as const); + +export type ThemeBrandToken = (typeof themeBrandTokens)[number]; + +/** One problem in a theme definition: where it is, relative to the definition, and what it is. */ +export interface ThemeIssue { + path: string[]; + message: string; +} + +const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/; +const cssLengthPattern = /^(0|\d*\.?\d+(px|rem|em))$/; +const cssLengthDifferencePattern = + /^calc\(\s*\d*\.?\d+(px|rem|em)\s+[-+]\s+\d*\.?\d+(px|rem|em)\s*\)$/; +const cssDurationPattern = /^\d*\.?\d+(ms|s)$/; +const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +const shadowLengthPattern = /^(-?(0|\d*\.?\d+[a-z]+)|(calc|min|max|clamp)\(.*\))$/i; +const shadowColorPattern = /^(#[0-9a-f]{3,8}|[a-z]+|[a-z-]+\(.*\))$/i; + +function isLinearGradient(value: string): boolean { + if (!value.startsWith('linear-gradient(') || /url\s*\(|image-set/i.test(value)) { + return false; + } + let depth = 0; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + if (depth === 0) { + return i === value.length - 1; + } + if (depth < 0) { + return false; + } + } + } + return false; +} + +export const isThemeRGB = (value: unknown): value is string => { + if (typeof value !== 'string') { + return false; + } + const match = value.match(rgbPattern); + return match !== null && match.slice(1).every((channel) => Number(channel) <= 255); +}; + +/** The bare form, or one `calc()` of two unit-bearing lengths (a bare `0` is a number there): + * the small radius defaults keep a px offset. */ +const isLength = (value: unknown): value is string => + typeof value === 'string' && + (cssLengthPattern.test(value) || cssLengthDifferencePattern.test(value)); +const isFontFamily = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0 && !/[;{}]/.test(value); + +/** Splits on `separator` outside parentheses, so `rgb(0, 0, 0)` stays one part. Empty parts are + * kept, so a stray comma stays visible to the caller. */ +function splitTopLevel(value: string, separator: RegExp): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + } + if (depth === 0 && separator.test(char)) { + parts.push(current); + current = ''; + continue; + } + current += char; + } + parts.push(current); + return parts.map((part) => part.trim()); +} + +/** A named color is indistinguishable from any other word without the browser's color parser, + * so outside a browser the pattern decides. */ +const isShadowColor = (token: string): boolean => + globalThis.CSS?.supports?.('color', token) ?? shadowColorPattern.test(token); + +/** One layer: two to four lengths, optionally `inset` and one color, per the box-shadow grammar. */ +function isShadowLayer(layer: string): boolean { + const tokens = splitTopLevel(layer, /\s/).filter((token) => token.length > 0); + const lengths = tokens.filter((token) => shadowLengthPattern.test(token)).length; + const insets = tokens.filter((token) => token.toLowerCase() === 'inset').length; + const colors = tokens.filter( + (token) => !shadowLengthPattern.test(token) && token.toLowerCase() !== 'inset', + ); + return ( + lengths >= 2 && lengths <= 4 && insets <= 1 && colors.length <= 1 && colors.every(isShadowColor) + ); +} + +/** + * A shadow must be concrete: a browser defers its check of any value holding `var()`, `env()` or + * `attr()` until substitution, so such a value could never be validated before it reaches the + * ring layers. + */ +const isShadow = (value: unknown): value is string => { + if (typeof value !== 'string' || /[;{}]|url\s*\(|(var|env|attr)\s*\(/i.test(value)) { + return false; + } + if (value.trim().toLowerCase() === 'none') { + return true; + } + const layers = splitTopLevel(value, /,/); + if (layers.some((layer) => layer.length === 0) || !layers.every(isShadowLayer)) { + return false; + } + return globalThis.CSS?.supports?.('box-shadow', value) ?? true; +}; +const isDuration = (value: unknown): value is string => + typeof value === 'string' && cssDurationPattern.test(value); + +export const isPlainThemeRecord = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + try { + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype.constructor?.name === 'Object'; + } catch { + return false; + } +}; + +const appearanceValidators = { + controlRadius: isLength, + roundControlRadius: isLength, + surfaceRadius: isLength, + largeSurfaceRadius: isLength, + radiusSm: isLength, + radiusMd: isLength, + radiusLg: isLength, + radiusXl: isLength, + radius2xl: isLength, + radius3xl: isLength, + controlHeight: isLength, + spaceCompact: isLength, + spaceNormal: isLength, + fontFamily: isFontFamily, + monoFontFamily: isFontFamily, + /** Released themes may hold `var()` here, so this role keeps its original, looser check. */ + elevationSurface: (value: unknown) => + typeof value === 'string' && value.trim().length > 0 && !/[;{}]|url\s*\(/i.test(value), + shadow2xs: isShadow, + shadowXs: isShadow, + shadowSm: isShadow, + shadowMd: isShadow, + shadowLg: isShadow, + shadowXl: isShadow, + shadow2xl: isShadow, + motionFast: isDuration, + motionNormal: isDuration, +} satisfies Record boolean>; + +export type ThemeAppearanceToken = keyof typeof appearanceValidators; + +export const themeAppearanceTokens = Object.freeze( + Object.keys(appearanceValidators) as ThemeAppearanceToken[], +); + +export const isThemeAppearanceToken = (key: string): key is ThemeAppearanceToken => + Object.prototype.hasOwnProperty.call(appearanceValidators, key); + +const colorTokenSet: ReadonlySet = new Set(themeColorTokens); +const brandTokenSet: ReadonlySet = new Set(themeBrandTokens); +const themeModes = ['light', 'dark'] as const; + +/** + * A token added after this reader shipped is ignored rather than rejected, so a newer definition + * degrades to the defaults for what this version cannot paint instead of losing every value it + * can. It never reaches the DOM, but it must still look like a token: a camelCase name and a + * plain CSS value, never a declaration or rule break. + */ +const isFutureAppearance = (key: string, value: unknown): boolean => + /^[a-z][a-zA-Z0-9]*$/.test(key) && typeof value === 'string' && !/[;{}<>]|url\s*\(/i.test(value); + +const issue = (path: string[], message: string): ThemeIssue => ({ path, message }); + +/** The appearance tokens this reader does not know, which a resolved theme leaves out. */ +export function collectThemeWarningIssues(theme: unknown): ThemeIssue[] { + if (!isPlainThemeRecord(theme) || !isPlainThemeRecord(theme.modes)) { + return []; + } + const modes = theme.modes; + return themeModes.flatMap((mode) => { + const definition = modes[mode]; + const appearance = isPlainThemeRecord(definition) ? definition.appearance : undefined; + if (!isPlainThemeRecord(appearance)) { + return []; + } + return Object.keys(appearance) + .filter((key) => !isThemeAppearanceToken(key)) + .map((key) => + issue( + ['modes', mode, 'appearance', key], + `Unknown ${mode} appearance token ignored: ${key}`, + ), + ); + }); +} + +/** Shared by the theme-wide `brands` and each mode's override block. */ +function collectBrandIssues(brands: unknown, path: string[]): ThemeIssue[] { + if (!isPlainThemeRecord(brands)) { + return []; + } + + return Object.entries(brands).flatMap(([key, value]) => { + if (!brandTokenSet.has(key)) { + return [issue([...path, key], `Unknown brand token: ${key}`)]; + } + /** Only the glyph is a flat colour; a fill may also be a gradient. */ + const isColorOnly = key === 'provider-foreground'; + const isValidBrand = + typeof value === 'string' && + (isColorOnly + ? hexColorPattern.test(value) + : hexColorPattern.test(value) || isLinearGradient(value)); + return value !== undefined && !isValidBrand + ? [issue([...path, key], `Invalid brand value for ${key}: ${value}`)] + : []; + }); +} + +function collectModeIssues(mode: 'light' | 'dark', definition: unknown): ThemeIssue[] { + const base = ['modes', mode]; + if (definition === undefined) { + return []; + } + if (!isPlainThemeRecord(definition)) { + return [issue(base, `Theme mode ${mode} must be an object`)]; + } + + const issues: ThemeIssue[] = Object.keys(definition) + .filter((key) => key !== 'colors' && key !== 'appearance' && key !== 'brands') + .map((key) => issue([...base, key], `Unknown ${mode} theme field: ${key}`)); + + const { colors, appearance, brands } = definition; + if (colors !== undefined && !isPlainThemeRecord(colors)) { + issues.push(issue([...base, 'colors'], `Theme colors for ${mode} must be an object`)); + } else { + Object.entries(colors ?? {}).forEach(([key, value]) => { + const path = [...base, 'colors', key]; + if (!colorTokenSet.has(key)) { + issues.push(issue(path, `Unknown color token: ${key}`)); + return; + } + if (value !== undefined && !isThemeRGB(value)) { + issues.push(issue(path, `Invalid RGB value for ${key}: ${value}`)); + } + }); + } + + if (appearance !== undefined && !isPlainThemeRecord(appearance)) { + issues.push(issue([...base, 'appearance'], `Theme appearance for ${mode} must be an object`)); + } else { + Object.entries(appearance ?? {}).forEach(([key, value]) => { + const isValid = isThemeAppearanceToken(key) + ? appearanceValidators[key](value) + : isFutureAppearance(key, value); + if (value !== undefined && !isValid) { + issues.push( + issue([...base, 'appearance', key], `Invalid appearance value for ${key}: ${value}`), + ); + } + }); + } + + if (brands !== undefined && !isPlainThemeRecord(brands)) { + issues.push(issue([...base, 'brands'], `Theme brands for ${mode} must be an object`)); + } else { + issues.push(...collectBrandIssues(brands, [...base, 'brands'])); + } + return issues; +} + +/** Every reason a definition cannot be painted; empty when it can. */ +export function collectThemeIssues(theme: unknown): ThemeIssue[] { + if (!isPlainThemeRecord(theme)) { + return [issue([], 'Theme definition must be an object')]; + } + + const issues: ThemeIssue[] = Object.keys(theme) + .filter((key) => key !== 'version' && key !== 'name' && key !== 'modes' && key !== 'brands') + .map((key) => issue([key], `Unknown theme field: ${key}`)); + + if (theme.version !== THEME_VERSION) { + issues.push(issue(['version'], `Unsupported theme version: ${theme.version}`)); + } + if (typeof theme.name !== 'string' || !theme.name.trim()) { + issues.push(issue(['name'], 'Theme name is required')); + } + const modes = theme.modes; + if (!isPlainThemeRecord(modes)) { + issues.push(issue(['modes'], 'Theme modes must be an object')); + return issues; + } + + Object.keys(modes) + .filter((mode) => mode !== 'light' && mode !== 'dark') + .forEach((mode) => issues.push(issue(['modes', mode], `Unknown theme mode: ${mode}`))); + + themeModes.forEach((mode) => issues.push(...collectModeIssues(mode, modes[mode]))); + + if (theme.brands !== undefined && !isPlainThemeRecord(theme.brands)) { + issues.push(issue(['brands'], 'Theme brands must be an object')); + } else { + issues.push(...collectBrandIssues(theme.brands, ['brands'])); + } + + return issues; +} From c7c4bb1f816f4f1643af1d42ec5b717f8d09d3e9 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:21:52 +0200 Subject: [PATCH 2/2] fix: Reject Unknown Bundled Theme Names at Config Load A misspelled bundled name such as clickhous passed the server and was rejected only in the browser. The bundled names now live in librechat-data-provider; the client's theme map is typed against that list and the loader drops a name outside it with a warning. --- client/src/Providers/DeploymentTheme.tsx | 8 ++++---- .../mock/scenarios/yaml-theme-fallback.spec.ts | 11 +++++++++++ .../src/app/__fixtures__/theme/bundled.yaml | 5 +++++ .../app/__fixtures__/theme/unknown-name.yaml | 5 +++++ packages/api/src/app/loader.spec.ts | 18 ++++++++++++++++++ packages/api/src/app/theme.ts | 14 +++++++++++++- packages/data-provider/src/theme.ts | 8 ++++++++ 7 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/app/__fixtures__/theme/bundled.yaml create mode 100644 packages/api/src/app/__fixtures__/theme/unknown-name.yaml diff --git a/client/src/Providers/DeploymentTheme.tsx b/client/src/Providers/DeploymentTheme.tsx index fadafaeb2bd..d34b3104178 100644 --- a/client/src/Providers/DeploymentTheme.tsx +++ b/client/src/Providers/DeploymentTheme.tsx @@ -8,8 +8,8 @@ import { createContext, useLayoutEffect, } from 'react'; -import { QueryKeys } from 'librechat-data-provider'; import { notifyManager, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, isBundledThemeName } from 'librechat-data-provider'; import { ThemeProvider, clickHouseTheme, @@ -17,15 +17,15 @@ import { fromLegacyTheme, validateThemeDefinition, } from '@librechat/client'; +import type { TInterfaceConfig, BundledThemeName } from 'librechat-data-provider'; import type { IThemeRGB, ThemeDefinition } from '@librechat/client'; -import type { TInterfaceConfig } from 'librechat-data-provider'; import type { ComponentProps } from 'react'; import { getThemeFromEnv } from '~/utils/getThemeFromEnv'; import { useGetStartupConfig } from '~/data-provider'; type DeploymentThemeValue = TInterfaceConfig['theme']; -const bundledThemes: Readonly> = { +const bundledThemes: Readonly> = { librechat: libreChatTheme, clickhouse: clickHouseTheme, }; @@ -41,7 +41,7 @@ export function resolveDeploymentTheme(theme: DeploymentThemeValue): ThemeDefini } if (typeof theme === 'string') { - const definition = Object.hasOwn(bundledThemes, theme) ? bundledThemes[theme] : undefined; + const definition = isBundledThemeName(theme) ? bundledThemes[theme] : undefined; if (!definition) { console.warn(`[DeploymentTheme] Ignoring unknown interface.theme "${theme}"`); } diff --git a/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts b/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts index 83082816be8..66c07213b83 100644 --- a/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts +++ b/e2e/specs/mock/scenarios/yaml-theme-fallback.spec.ts @@ -83,6 +83,17 @@ test.describe('interface.theme in librechat.yaml', () => { ); }); + test('a misspelled bundled theme name falls back with a warning @scenario:yaml-theme-unknown-name-falls-back', () => { + const { outcome, log } = loadFixture('unknown-name'); + + expect(outcome.exited).toBeUndefined(); + expect(outcome.interface).toEqual({ modelSelect: true }); + expect(log).toContain( + 'interface.theme: Unknown bundled theme "clickhous", expected one of: librechat, clickhouse', + ); + expect(loadFixture('bundled').outcome.interface?.theme).toBe('clickhouse'); + }); + test('a valid inline theme loads exactly as written @scenario:yaml-theme-valid-unchanged', () => { const { outcome, log } = loadFixture('valid'); diff --git a/packages/api/src/app/__fixtures__/theme/bundled.yaml b/packages/api/src/app/__fixtures__/theme/bundled.yaml new file mode 100644 index 00000000000..7e3a6c8ebbf --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/bundled.yaml @@ -0,0 +1,5 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: clickhouse diff --git a/packages/api/src/app/__fixtures__/theme/unknown-name.yaml b/packages/api/src/app/__fixtures__/theme/unknown-name.yaml new file mode 100644 index 00000000000..7881ecb4821 --- /dev/null +++ b/packages/api/src/app/__fixtures__/theme/unknown-name.yaml @@ -0,0 +1,5 @@ +version: 1.3.17 +cache: true +interface: + modelSelect: true + theme: clickhous diff --git a/packages/api/src/app/loader.spec.ts b/packages/api/src/app/loader.spec.ts index 4ad40ded1eb..d0c523c6f21 100644 --- a/packages/api/src/app/loader.spec.ts +++ b/packages/api/src/app/loader.spec.ts @@ -105,6 +105,24 @@ describe('createCustomConfigLoader interface.theme', () => { expect(warnings(warn)).toContain('interface.theme.modes.light.colors.rgb-surface-primary:'); }); + it('drops a bundled theme name that does not exist', async () => { + const config = await load('unknown-name'); + + expect(exit).not.toHaveBeenCalled(); + expect(config?.interface).not.toHaveProperty('theme'); + expect(config?.interface?.modelSelect).toBe(true); + expect(warnings(warn)).toContain( + 'interface.theme: Unknown bundled theme "clickhous", expected one of: librechat, clickhouse', + ); + }); + + it('keeps a bundled theme name', async () => { + const config = await load('bundled'); + + expect(config?.interface?.theme).toBe('clickhouse'); + expect(warn).not.toHaveBeenCalled(); + }); + it('keeps a theme whose only problem is an appearance token this version ignores', async () => { const config = await load('unknown-appearance'); diff --git a/packages/api/src/app/theme.ts b/packages/api/src/app/theme.ts index 09ab928333e..f604e99078f 100644 --- a/packages/api/src/app/theme.ts +++ b/packages/api/src/app/theme.ts @@ -1,5 +1,7 @@ import { + bundledThemeNames, collectThemeIssues, + isBundledThemeName, isPlainThemeRecord, deploymentThemeSchema, collectThemeWarningIssues, @@ -24,7 +26,17 @@ function collectErrors(theme: unknown): ThemeIssue[] { if (typeof theme !== 'string' && !isPlainThemeRecord(theme)) { return [{ path: [], message: 'Expected a bundled theme name or an inline theme definition' }]; } - const issues = typeof theme === 'string' ? [] : collectThemeIssues(theme); + if (typeof theme === 'string') { + return isBundledThemeName(theme) + ? [] + : [ + { + path: [], + message: `Unknown bundled theme "${theme}", expected one of: ${bundledThemeNames.join(', ')}`, + }, + ]; + } + const issues = collectThemeIssues(theme); if (issues.length > 0) { return issues; } diff --git a/packages/data-provider/src/theme.ts b/packages/data-provider/src/theme.ts index 73ad7e163bb..e1b321dea54 100644 --- a/packages/data-provider/src/theme.ts +++ b/packages/data-provider/src/theme.ts @@ -5,6 +5,14 @@ */ export const THEME_VERSION = 1 as const; +/** The names `interface.theme` may give instead of an inline definition. */ +export const bundledThemeNames = Object.freeze(['librechat', 'clickhouse'] as const); + +export type BundledThemeName = (typeof bundledThemeNames)[number]; + +export const isBundledThemeName = (value: string): value is BundledThemeName => + (bundledThemeNames as readonly string[]).includes(value); + export const themeColorTokens = Object.freeze([ 'rgb-text-primary', 'rgb-text-secondary',