diff --git a/Dockerfile b/Dockerfile index 19d275eb31..a722b38288 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN \ # Allow mounting of these files, which have no default touch .env ; \ # Create directories for the volumes to inherit the correct permissions - mkdir -p /app/client/public/images /app/logs /app/uploads ; \ + mkdir -p /app/client/public/images /app/logs /app/uploads /app/custom ; \ npm config set fetch-retry-maxtimeout 600000 ; \ npm config set fetch-retries 5 ; \ npm config set fetch-retry-mintimeout 15000 ; \ diff --git a/Dockerfile.multi b/Dockerfile.multi index bf5570f386..487017ffe3 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -81,6 +81,8 @@ COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist COPY --from=client-build /app/client/dist ./client/dist +RUN mkdir -p /app/custom +COPY custom/theme.example.css ./custom/theme.example.css WORKDIR /app/api EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/api/config/paths.js b/api/config/paths.js index 165e9e6cd4..a00405452d 100644 --- a/api/config/paths.js +++ b/api/config/paths.js @@ -1,5 +1,9 @@ const path = require('path'); +const customPath = process.env.CUSTOM_THEME_PATH + ? path.resolve(process.env.CUSTOM_THEME_PATH) + : path.resolve(__dirname, '..', '..', 'custom'); + module.exports = { root: path.resolve(__dirname, '..', '..'), uploads: path.resolve(__dirname, '..', '..', 'uploads'), @@ -9,6 +13,7 @@ module.exports = { fonts: path.resolve(__dirname, '..', '..', 'client', 'public', 'fonts'), assets: path.resolve(__dirname, '..', '..', 'client', 'public', 'assets'), imageOutput: path.resolve(__dirname, '..', '..', 'client', 'public', 'images'), + customTheme: customPath, structuredTools: path.resolve(__dirname, '..', 'app', 'clients', 'tools', 'structured'), pluginManifest: path.resolve(__dirname, '..', 'app', 'clients', 'tools', 'manifest.json'), }; diff --git a/api/server/index.js b/api/server/index.js index d26a203c0a..c14de233e8 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -92,6 +92,32 @@ const startServer = async () => { } } + // Custom theme CSS: inject a tag if a custom theme.css file exists + const customThemePath = appConfig.paths.customTheme; + const customThemeCssPath = path.join(customThemePath, 'theme.css'); + const hasCustomTheme = fs.existsSync(customThemeCssPath); + if (hasCustomTheme) { + logger.info(`Custom theme CSS found at ${customThemeCssPath}`); + // Use a relative URL so it resolves correctly under any (subdirectory deployments) + indexHTML = indexHTML.replace( + '', + ' \n ', + ); + } + + /** Sends the in-memory index.html with per-request lang injection */ + const serveIndexHtml = (req, res) => { + res.set({ + 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', + Pragma: process.env.INDEX_PRAGMA || 'no-cache', + Expires: process.env.INDEX_EXPIRES || '0', + }); + const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; + const saneLang = lang.replace(/"/g, '"'); + res.type('html'); + res.send(indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`)); + }; + app.get('/health', (_req, res) => res.status(200).send('OK')); /* Middleware */ @@ -123,10 +149,20 @@ const startServer = async () => { console.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + /* Explicit /index.html route — must precede staticCache so it serves the + in-memory HTML (with custom theme link, correct lang, etc.) rather than + the raw file from disk. Registered after cookie/compression middleware + so req.cookies and response compression are available. */ + app.get('/index.html', serveIndexHtml); + app.use(staticCache(appConfig.paths.dist)); app.use(staticCache(appConfig.paths.fonts)); app.use(staticCache(appConfig.paths.assets)); + if (hasCustomTheme) { + app.use('/custom', staticCache(customThemePath, { noCache: true, skipGzipScan: true })); + } + if (!ALLOW_SOCIAL_LOGIN) { console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); } @@ -190,20 +226,7 @@ const startServer = async () => { app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ - app.use((req, res) => { - res.set({ - 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', - Pragma: process.env.INDEX_PRAGMA || 'no-cache', - Expires: process.env.INDEX_EXPIRES || '0', - }); - - const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; - const saneLang = lang.replace(/"/g, '"'); - let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); - - res.type('html'); - res.send(updatedIndexHtml); - }); + app.use(serveIndexHtml); /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ app.use(ErrorController); diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 7b3d062fce..20cc39a04e 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -11,6 +11,7 @@ jest.mock('~/server/services/Config', () => ({ dist: '/tmp/dist', fonts: '/tmp/fonts', assets: '/tmp/assets', + customTheme: '/tmp/custom', }, fileStrategy: 'local', imageOutputType: 'PNG', diff --git a/custom/theme.example.css b/custom/theme.example.css new file mode 100644 index 0000000000..d3e646794e --- /dev/null +++ b/custom/theme.example.css @@ -0,0 +1,267 @@ +/* + * LibreChat Custom Theme + * + * This file documents all theme variables with their default values. + * To use it, copy this file to `theme.css` in the same directory and + * change whatever you want. + * + * How it works: + * - The server checks for `custom/theme.css` on startup + * - If found, a tag is injected at the end of , after all built-in styles + * - This gives your overrides the highest precedence in the cascade by load order + * - The file is served with no-cache headers, so CSS edits take effect on page reload + * (no container or server restart needed) + * + * Docker setup: + * Mount this directory via docker-compose.override.yml: + * + * services: + * api: + * volumes: + * - ./custom:/app/custom + * + * Or mount a single file: + * + * services: + * api: + * volumes: + * - ./my-theme.css:/app/custom/theme.css + * + * Tips: + * - Use your browser's DevTools to inspect elements and find which variables + * or classes to override. + * - The `html` selector targets light mode; `.dark` targets dark mode. + * - To load custom fonts, add @font-face rules below and place the font + * files in this directory — they'll be served under /custom/. + */ + +/* ========================================================================== + * Base color palette + * + * Redefine these to shift the entire palette at once, or override the + * individual semantic variables below for more surgical control. + * ========================================================================== */ + +:root { + --white: #fff; + --black: #000; + + --gray-20: #ececf1; + --gray-50: #f7f7f8; + --gray-100: #ececec; + --gray-200: #e3e3e3; + --gray-300: #cdcdcd; + --gray-400: #999696; + --gray-500: #595959; + --gray-600: #424242; + --gray-700: #2f2f2f; + --gray-800: #212121; + --gray-850: #171717; + --gray-900: #0d0d0d; + + --green-50: #ecfdf5; + --green-100: #d1fae5; + --green-200: #a7f3d0; + --green-300: #6ee7b7; + --green-400: #34d399; + --green-500: #10b981; + --green-600: #059669; + --green-700: #047857; + --green-800: #065f46; + --green-900: #064e3b; + --green-950: #022c22; + + --red-50: #fef2f2; + --red-100: #fee2e2; + --red-200: #fecaca; + --red-300: #fca5a5; + --red-400: #f87171; + --red-500: #ef4444; + --red-600: #dc2626; + --red-700: #b91c1c; + --red-800: #991b1b; + --red-900: #7f1d1d; + --red-950: #450a0a; + + --amber-50: #fffbeb; + --amber-100: #fef3c7; + --amber-200: #fde68a; + --amber-300: #fcd34d; + --amber-400: #fbbf24; + --amber-500: #f59e0b; + --amber-600: #d97706; + --amber-700: #b45309; + --amber-800: #92400e; + --amber-900: #78350f; + --amber-950: #451a03; + + /* Typography */ + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --markdown-font-size: 1rem; +} + +/* ========================================================================== + * Light mode semantic variables (selector: html) + * ========================================================================== */ + +html { + --brand-purple: #ab68ff; + + /* Text */ + --presentation: var(--white); + --text-primary: var(--gray-800); + --text-secondary: var(--gray-600); + --text-secondary-alt: var(--gray-500); + --text-tertiary: var(--gray-500); + --text-warning: var(--amber-500); + --text-destructive: var(--red-600); + + /* Focus ring */ + --ring-primary: var(--gray-500); + + /* Header */ + --header-primary: var(--white); + --header-hover: var(--gray-50); + --header-button-hover: var(--gray-50); + + /* Surfaces */ + --surface-active: var(--gray-100); + --surface-active-alt: var(--gray-200); + --surface-hover: var(--gray-200); + --surface-hover-alt: var(--gray-300); + --surface-primary: var(--white); + --surface-primary-alt: var(--gray-50); + --surface-primary-contrast: var(--gray-100); + --surface-secondary: var(--gray-50); + --surface-secondary-alt: var(--gray-200); + --surface-tertiary: var(--gray-100); + --surface-tertiary-alt: var(--white); + --surface-dialog: var(--white); + --surface-chat: var(--white); + --surface-submit: var(--green-700); + --surface-submit-hover: var(--green-800); + --surface-destructive: var(--red-700); + --surface-destructive-hover: var(--red-800); + + /* Borders */ + --border-light: var(--gray-200); + --border-medium: var(--gray-300); + --border-medium-alt: var(--gray-300); + --border-heavy: var(--gray-400); + --border-xheavy: var(--gray-500); + --border-destructive: var(--red-600); + + /* HSL-based variables used by shadcn/ui components */ + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + --switch-unchecked: 0 0% 58%; +} + +/* ========================================================================== + * Dark mode semantic variables (selector: .dark) + * ========================================================================== */ + +.dark { + --brand-purple: #ab68ff; + + /* Text */ + --presentation: var(--gray-800); + --text-primary: var(--gray-100); + --text-secondary: var(--gray-300); + --text-secondary-alt: var(--gray-400); + --text-tertiary: var(--gray-500); + --text-warning: var(--amber-500); + --text-destructive: var(--red-600); + + /* Header */ + --header-primary: var(--gray-700); + --header-hover: var(--gray-600); + --header-button-hover: var(--gray-700); + + /* Surfaces */ + --surface-active: var(--gray-500); + --surface-active-alt: var(--gray-700); + --surface-hover: var(--gray-600); + --surface-hover-alt: var(--gray-600); + --surface-primary: var(--gray-900); + --surface-primary-alt: var(--gray-850); + --surface-primary-contrast: var(--gray-850); + --surface-secondary: var(--gray-800); + --surface-secondary-alt: var(--gray-800); + --surface-tertiary: var(--gray-700); + --surface-tertiary-alt: var(--gray-700); + --surface-dialog: var(--gray-850); + --surface-chat: var(--gray-700); + --surface-submit: var(--green-700); + --surface-submit-hover: var(--green-800); + --surface-destructive: var(--red-800); + --surface-destructive-hover: var(--red-900); + + /* Borders */ + --border-light: var(--gray-700); + --border-medium: var(--gray-600); + --border-medium-alt: var(--gray-600); + --border-heavy: var(--gray-500); + --border-xheavy: var(--gray-400); + --border-destructive: var(--red-500); + + /* HSL-based variables used by shadcn/ui components */ + --background: 0 0% 7%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 40.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --switch-unchecked: 0 0% 40%; +} + +/* ========================================================================== + * Custom font example + * + * Place font files in this directory; they are served under /custom/. + * ========================================================================== */ + +/* +@font-face { + font-family: 'MyFont'; + src: url('/custom/MyFont.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +html { + font-family: 'MyFont', sans-serif; +} +*/ diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 490436eb97..83468ba03a 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -60,6 +60,20 @@ # source: ./your_cert.pem # target: /app/your_cert.pem +# # CUSTOM THEME CSS +# # Mount a directory containing a theme.css file to override frontend styles. +# # The theme.css file can override CSS custom properties and any other styles. +# # Changes to the mounted file take effect on page reload without restarting the container. +# api: +# volumes: +# - type: bind +# source: ./custom +# target: /app/custom +# # To use a path other than /app/custom inside the container, also set CUSTOM_THEME_PATH: +# # api: +# # environment: +# # - CUSTOM_THEME_PATH=/app/my-theme + # # ADD MONGO-EXPRESS # mongo-express: # image: mongo-express diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts index 91407b06c4..b7afbd7263 100644 --- a/packages/data-schemas/src/app/service.ts +++ b/packages/data-schemas/src/app/service.ts @@ -42,6 +42,7 @@ export type Paths = { fonts: string; assets: string; imageOutput: string; + customTheme: string; structuredTools: string; pluginManifest: string; };