mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge 110098a555 into 8ed0bcf5ca
This commit is contained in:
commit
b018fe3fc7
8 changed files with 328 additions and 15 deletions
|
|
@ -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 ; \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, '"');
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
267
custom/theme.example.css
Normal 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;
|
||||
}
|
||||
*/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type Paths = {
|
|||
fonts: string;
|
||||
assets: string;
|
||||
imageOutput: string;
|
||||
customTheme: string;
|
||||
structuredTools: string;
|
||||
pluginManifest: string;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue