This commit is contained in:
Linus Schlumberger 2026-04-04 04:43:14 +02:00 committed by GitHub
commit b018fe3fc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 328 additions and 15 deletions

View file

@ -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 ; \

View file

@ -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

View file

@ -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'),
};

View file

@ -92,6 +92,32 @@ const startServer = async () => {
}
}
// Custom theme CSS: inject a <link> 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 <base href> (subdirectory deployments)
indexHTML = indexHTML.replace(
'</head>',
' <link rel="stylesheet" href="custom/theme.css">\n </head>',
);
}
/** 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, '&quot;');
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, '&quot;');
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);

View file

@ -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',

267
custom/theme.example.css Normal file
View file

@ -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 <link> tag is injected at the end of <head>, 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;
}
*/

View file

@ -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

View file

@ -42,6 +42,7 @@ export type Paths = {
fonts: string;
assets: string;
imageOutput: string;
customTheme: string;
structuredTools: string;
pluginManifest: string;
};