chore: import upstream snapshot with attribution
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:12 +08:00
commit e115934061
3584 changed files with 848449 additions and 0 deletions
@@ -0,0 +1,143 @@
const nodemailer = require('nodemailer');
const { readFileAsString } = require('@librechat/api');
jest.mock('nodemailer');
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('@librechat/api', () => ({
logAxiosError: jest.fn(),
isEnabled: jest.fn((val) => val === 'true' || val === true),
readFileAsString: jest.fn(),
}));
const savedEnv = { ...process.env };
const mockSendMail = jest.fn().mockResolvedValue({ messageId: 'test-id' });
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...savedEnv };
process.env.EMAIL_HOST = 'smtp.example.com';
process.env.EMAIL_PORT = '587';
process.env.EMAIL_FROM = 'noreply@example.com';
process.env.APP_TITLE = 'TestApp';
delete process.env.EMAIL_USERNAME;
delete process.env.EMAIL_PASSWORD;
delete process.env.MAILGUN_API_KEY;
delete process.env.MAILGUN_DOMAIN;
delete process.env.EMAIL_SERVICE;
delete process.env.EMAIL_ENCRYPTION;
delete process.env.EMAIL_ENCRYPTION_HOSTNAME;
delete process.env.EMAIL_ALLOW_SELFSIGNED;
readFileAsString.mockResolvedValue({ content: '<p>{{name}}</p>' });
nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail });
});
afterAll(() => {
process.env = savedEnv;
});
/** Loads a fresh copy of sendEmail so process.env reads are re-evaluated. */
function loadSendEmail() {
jest.resetModules();
jest.mock('nodemailer', () => ({
createTransport: jest.fn().mockReturnValue({ sendMail: mockSendMail }),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));
jest.mock('@librechat/api', () => ({
logAxiosError: jest.fn(),
isEnabled: jest.fn((val) => val === 'true' || val === true),
readFileAsString: jest.fn().mockResolvedValue({ content: '<p>{{name}}</p>' }),
}));
return require('../sendEmail');
}
const baseParams = {
email: 'user@example.com',
subject: 'Test',
payload: { name: 'User' },
template: 'test.handlebars',
};
describe('sendEmail SMTP auth assembly', () => {
it('includes auth when both EMAIL_USERNAME and EMAIL_PASSWORD are set', async () => {
process.env.EMAIL_USERNAME = 'smtp_user';
process.env.EMAIL_PASSWORD = 'smtp_pass';
const sendEmail = loadSendEmail();
const { createTransport } = require('nodemailer');
await sendEmail(baseParams);
expect(createTransport).toHaveBeenCalledTimes(1);
const transporterOptions = createTransport.mock.calls[0][0];
expect(transporterOptions.auth).toEqual({
user: 'smtp_user',
pass: 'smtp_pass',
});
});
it('omits auth when both EMAIL_USERNAME and EMAIL_PASSWORD are absent', async () => {
const sendEmail = loadSendEmail();
const { createTransport } = require('nodemailer');
await sendEmail(baseParams);
expect(createTransport).toHaveBeenCalledTimes(1);
const transporterOptions = createTransport.mock.calls[0][0];
expect(transporterOptions.auth).toBeUndefined();
});
it('omits auth and logs a warning when only EMAIL_USERNAME is set', async () => {
process.env.EMAIL_USERNAME = 'smtp_user';
const sendEmail = loadSendEmail();
const { createTransport } = require('nodemailer');
const { logger: freshLogger } = require('@librechat/data-schemas');
await sendEmail(baseParams);
const transporterOptions = createTransport.mock.calls[0][0];
expect(transporterOptions.auth).toBeUndefined();
expect(freshLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('EMAIL_USERNAME and EMAIL_PASSWORD must both be set'),
);
});
it('omits auth and logs a warning when only EMAIL_PASSWORD is set', async () => {
process.env.EMAIL_PASSWORD = 'smtp_pass';
const sendEmail = loadSendEmail();
const { createTransport } = require('nodemailer');
const { logger: freshLogger } = require('@librechat/data-schemas');
await sendEmail(baseParams);
const transporterOptions = createTransport.mock.calls[0][0];
expect(transporterOptions.auth).toBeUndefined();
expect(freshLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('EMAIL_USERNAME and EMAIL_PASSWORD must both be set'),
);
});
it('does not log a warning when both credentials are properly set', async () => {
process.env.EMAIL_USERNAME = 'smtp_user';
process.env.EMAIL_PASSWORD = 'smtp_pass';
const sendEmail = loadSendEmail();
const { logger: freshLogger } = require('@librechat/data-schemas');
await sendEmail(baseParams);
expect(freshLogger.warn).not.toHaveBeenCalled();
});
it('does not log a warning when both credentials are absent', async () => {
const sendEmail = loadSendEmail();
const { logger: freshLogger } = require('@librechat/data-schemas');
await sendEmail(baseParams);
expect(freshLogger.warn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,464 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
const request = require('supertest');
const zlib = require('zlib');
const staticCache = require('../staticCache');
const binaryParser = (res, callback) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
};
describe('staticCache', () => {
let app;
let testDir;
let testFile;
let indexFile;
let manifestFile;
let swFile;
beforeAll(() => {
// Create a test directory and files
testDir = path.join(__dirname, 'test-static');
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
// Create test files
testFile = path.join(testDir, 'test.js');
indexFile = path.join(testDir, 'index.html');
manifestFile = path.join(testDir, 'manifest.json');
swFile = path.join(testDir, 'sw.js');
const jsContent = 'console.log("test");';
const htmlContent = '<html><body>Test</body></html>';
const jsonContent = '{"name": "test"}';
const swContent = 'self.addEventListener("install", () => {});';
fs.writeFileSync(testFile, jsContent);
fs.writeFileSync(indexFile, htmlContent);
fs.writeFileSync(manifestFile, jsonContent);
fs.writeFileSync(swFile, swContent);
// Create precompressed versions of some files
fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent));
fs.writeFileSync(testFile + '.br', zlib.brotliCompressSync(jsContent));
fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }');
fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }'));
fs.writeFileSync(
path.join(testDir, 'test.css.br'),
zlib.brotliCompressSync('body { color: red; }'),
);
// Create a file that only exists in gzipped form
fs.writeFileSync(
path.join(testDir, 'only-gzipped.js.gz'),
zlib.gzipSync('console.log("only gzipped");'),
);
// Create a subdirectory for dist/images testing
const distImagesDir = path.join(testDir, 'dist', 'images');
fs.mkdirSync(distImagesDir, { recursive: true });
fs.writeFileSync(path.join(distImagesDir, 'logo.png'), 'fake-png-data');
});
afterAll(() => {
// Clean up test files
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
beforeEach(() => {
app = express();
// Clear environment variables
delete process.env.NODE_ENV;
delete process.env.STATIC_CACHE_S_MAX_AGE;
delete process.env.STATIC_CACHE_MAX_AGE;
delete process.env.ENABLE_STATIC_ASSET_BROTLI;
});
describe('cache headers in production', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should set standard cache headers for regular files', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/test.js').expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
});
it('should set no-cache headers for index.html', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/index.html').expect(200);
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
});
it('should set no-cache headers for manifest.json', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/manifest.json').expect(200);
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
});
it('should set no-cache headers for sw.js', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/sw.js').expect(200);
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
});
it('should not set cache headers for /dist/images/ files', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/dist/images/logo.png').expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=0');
});
it('should set no-cache headers when noCache option is true', async () => {
app.use(staticCache(testDir, { noCache: true }));
const response = await request(app).get('/test.js').expect(200);
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
});
});
describe('cache headers in non-production', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development';
});
it('should not set cache headers in development', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/test.js').expect(200);
// Our middleware should not set cache-control in non-production
// Express static might set its own default headers
const cacheControl = response.headers['cache-control'];
expect(cacheControl).toBe('public, max-age=0');
});
});
describe('environment variable configuration', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should use custom s-maxage from environment', async () => {
process.env.STATIC_CACHE_S_MAX_AGE = '3600';
// Need to re-require to pick up new env vars
jest.resetModules();
const freshStaticCache = require('../staticCache');
app.use(freshStaticCache(testDir));
const response = await request(app).get('/test.js').expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=3600');
});
it('should use custom max-age from environment', async () => {
process.env.STATIC_CACHE_MAX_AGE = '7200';
// Need to re-require to pick up new env vars
jest.resetModules();
const freshStaticCache = require('../staticCache');
app.use(freshStaticCache(testDir));
const response = await request(app).get('/test.js').expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=7200, s-maxage=86400');
});
it('should use both custom values from environment', async () => {
process.env.STATIC_CACHE_S_MAX_AGE = '1800';
process.env.STATIC_CACHE_MAX_AGE = '3600';
// Need to re-require to pick up new env vars
jest.resetModules();
const freshStaticCache = require('../staticCache');
app.use(freshStaticCache(testDir));
const response = await request(app).get('/test.js').expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=3600, s-maxage=1800');
});
});
describe('express-static-gzip behavior', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should serve Brotli files when client accepts Brotli encoding', async () => {
process.env.ENABLE_STATIC_ASSET_BROTLI = 'true';
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'br, gzip, deflate')
.buffer(true)
.parse(binaryParser)
.expect(200);
expect(response.headers['content-encoding']).toBe('br');
expect(response.headers['content-type']).toMatch(/javascript/);
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
expect(zlib.brotliDecompressSync(response.body).toString()).toBe('console.log("test");');
});
it('should prefer Brotli over gzip when both encodings are accepted', async () => {
process.env.ENABLE_STATIC_ASSET_BROTLI = 'true';
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.css')
.set('Accept-Encoding', 'gzip, br')
.buffer(true)
.parse(binaryParser)
.expect(200);
expect(response.headers['content-encoding']).toBe('br');
expect(response.headers['content-type']).toMatch(/css/);
expect(zlib.brotliDecompressSync(response.body).toString()).toBe('body { color: red; }');
});
it('should keep serving gzip when Brotli is not enabled', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'br, gzip, deflate')
.expect(200);
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.text).toBe('console.log("test");');
});
it('should serve gzipped files when client accepts gzip encoding', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'gzip, deflate')
.expect(200);
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.headers['content-type']).toMatch(/javascript/);
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
// Content should be decompressed by supertest
expect(response.text).toBe('console.log("test");');
});
it('should fall back to uncompressed files when client does not accept gzip', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'identity')
.expect(200);
expect(response.headers['content-encoding']).toBeUndefined();
expect(response.headers['content-type']).toMatch(/javascript/);
expect(response.text).toBe('console.log("test");');
});
it('should serve gzipped CSS files with correct content-type', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.css')
.set('Accept-Encoding', 'gzip')
.expect(200);
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.headers['content-type']).toMatch(/css/);
expect(response.text).toBe('body { color: red; }');
});
it('should serve uncompressed files when no gzipped version exists', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/manifest.json')
.set('Accept-Encoding', 'gzip')
.expect(200);
expect(response.headers['content-encoding']).toBeUndefined();
expect(response.headers['content-type']).toMatch(/json/);
expect(response.text).toBe('{"name": "test"}');
});
it('should handle files that only exist in gzipped form', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/only-gzipped.js')
.set('Accept-Encoding', 'gzip')
.expect(200);
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.headers['content-type']).toMatch(/javascript/);
expect(response.text).toBe('console.log("only gzipped");');
});
it('should return 404 for gzip-only files when client does not accept gzip', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/only-gzipped.js')
.set('Accept-Encoding', 'identity');
expect(response.status).toBe(404);
});
it('should handle cache headers correctly for gzipped content', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'gzip')
.expect(200);
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
expect(response.headers['content-type']).toMatch(/javascript/);
});
it('should preserve original MIME types for gzipped files', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));
const jsResponse = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'gzip')
.expect(200);
const cssResponse = await request(app)
.get('/test.css')
.set('Accept-Encoding', 'gzip')
.expect(200);
expect(jsResponse.headers['content-type']).toMatch(/javascript/);
expect(cssResponse.headers['content-type']).toMatch(/css/);
expect(jsResponse.headers['content-encoding']).toBe('gzip');
expect(cssResponse.headers['content-encoding']).toBe('gzip');
});
});
describe('skipGzipScan option comparison', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should use express.static (no gzip) when skipGzipScan is true', async () => {
app.use(staticCache(testDir, { skipGzipScan: true }));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'gzip')
.expect(200);
// Should NOT serve gzipped version even though client accepts it
expect(response.headers['content-encoding']).toBeUndefined();
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
expect(response.text).toBe('console.log("test");');
});
it('should use expressStaticGzip when skipGzipScan is false', async () => {
app.use(staticCache(testDir));
const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'gzip')
.expect(200);
// Should serve gzipped version when client accepts it
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
expect(response.text).toBe('console.log("test");');
});
});
describe('file serving', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should serve files correctly', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/test.js').expect(200);
expect(response.text).toBe('console.log("test");');
expect(response.headers['content-type']).toMatch(/javascript|text/);
});
it('should return 404 for non-existent files', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/nonexistent.js');
expect(response.status).toBe(404);
});
it('should serve HTML files', async () => {
app.use(staticCache(testDir));
const response = await request(app).get('/index.html').expect(200);
expect(response.text).toBe('<html><body>Test</body></html>');
expect(response.headers['content-type']).toMatch(/html/);
});
});
describe('edge cases', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should handle webmanifest files', async () => {
// Create a webmanifest file
const webmanifestFile = path.join(testDir, 'site.webmanifest');
fs.writeFileSync(webmanifestFile, '{"name": "test app"}');
app.use(staticCache(testDir));
const response = await request(app).get('/site.webmanifest').expect(200);
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
// Clean up
fs.unlinkSync(webmanifestFile);
});
it('should handle files in subdirectories', async () => {
const subDir = path.join(testDir, 'subdir');
fs.mkdirSync(subDir, { recursive: true });
const subFile = path.join(subDir, 'nested.js');
fs.writeFileSync(subFile, 'console.log("nested");');
app.use(staticCache(testDir));
const response = await request(app).get('/subdir/nested.js').expect(200);
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
expect(response.text).toBe('console.log("nested");');
// Clean up
fs.rmSync(subDir, { recursive: true, force: true });
});
});
});
@@ -0,0 +1,287 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) { .darkmode { background-color: #212121 !important; }
.darkmode p { color: #ffffff !important; } } @media only screen and (min-width: 520px) {
.u-row { width: 500px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 {
width: 500px !important; } } @media (max-width: 520px) { .u-row-container { max-width: 100%
!important; padding-left: 0px !important; padding-right: 0px !important; } .u-row .u-col {
min-width: 320px !important; max-width: 100% !important; display: block !important; } .u-row {
width: 100% !important; } .u-col { width: 100% !important; } .u-col>div { margin: 0 auto; } }
body { margin: 0; padding: 0; } table, tr, td { vertical-align: top; border-collapse:
collapse; } p { margin: 0; } .ie-container table, .mso-container table { table-layout: fixed;
} * { line-height: inherit; } a[x-apple-data-detectors='true'] { color: inherit !important;
text-decoration: none !important; } table, td { color: #ffffff; } #u_body a { color: #0000ee;
text-decoration: underline; }
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>You have been invited to join {{appName}}!</div>
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi,</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<p style='line-height: 140%;'>You have been invited to join {{appName}}. Click the
button below to create your account and get started.</p>
</p>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{inviteLink}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{inviteLink}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span
style='display:block;padding:10px 20px;line-height:120%;'
><span style='line-height: 16.8px;'>Create Account</span></span>
</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>
Hurry up, the invite will expiry in 7 days
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>
@@ -0,0 +1,250 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Your password has been updated successfully! </div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>
@@ -0,0 +1,344 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
p {
margin: 0;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
#u_body a {
color: #0000ee;
text-decoration: underline;
}
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>You have requested to reset your password.
</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Hi {{name}},</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<p style='line-height: 140%;'>Please click the button below to
reset your password.</p>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{link}}" style="height:37px; v-text-anchor:middle; width:142px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{link}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span
style='display:block;padding:10px 20px;line-height:120%;'
><span style='line-height: 16.8px;'>Reset Password</span></span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>If you did not request a password reset, please ignore this
email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>
@@ -0,0 +1,347 @@
<html
xmlns='http://www.w3.org/1999/xhtml'
xmlns:v='urn:schemas-microsoft-com:vml'
xmlns:o='urn:schemas-microsoft-com:office:office'
>
<head>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta http-equiv='Content-Type' content='text/html; charset=UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='x-apple-disable-message-reformatting' />
<meta name='color-scheme' content='light dark' />
<!--[if !mso]><!-->
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<!--<![endif]-->
<title></title>
<style type='text/css'>
@media (prefers-color-scheme: dark) {
.darkmode {
background-color: #212121 !important;
}
.darkmode p {
color: #ffffff !important;
}
}
@media only screen and (min-width: 520px) {
.u-row {
width: 500px !important;
}
.u-row .u-col {
vertical-align: top;
}
.u-row .u-col-100 {
width: 500px !important;
}
}
@media (max-width: 520px) {
.u-row-container {
max-width: 100% !important;
padding-left: 0px !important;
padding-right: 0px !important;
}
.u-row .u-col {
min-width: 320px !important;
max-width: 100% !important;
display: block !important;
}
.u-row {
width: 100% !important;
}
.u-col {
width: 100% !important;
}
.u-col > div {
margin: 0 auto;
}
}
body {
margin: 0;
padding: 0;
}
table,
tr,
td {
vertical-align: top;
border-collapse: collapse;
}
.ie-container table,
.mso-container table {
table-layout: fixed;
}
* {
line-height: inherit;
}
a[x-apple-data-detectors='true'] {
color: inherit !important;
text-decoration: none !important;
}
table,
td {
color: #ffffff;
}
#u_body a {
color: #0000ee;
text-decoration: underline;
}
</style>
</head>
<body
class='clean-body u_body'
style='margin: 0;padding: 0;-webkit-text-size-adjust: 100%;background-color: #212121;color: #ffffff'
>
<!--[if IE]><div class="ie-container"><![endif]-->
<!--[if mso]><div class="mso-container"><![endif]-->
<table
id='u_body'
style='border-collapse: collapse;table-layout: fixed;border-spacing: 0;mso-table-lspace: 0pt;mso-table-rspace: 0pt;vertical-align: top;min-width: 320px;Margin: 0 auto;background-color: #212121;width:100%'
cellpadding='0'
cellspacing='0'
>
<tbody>
<tr style='vertical-align: top'>
<td
style='word-break: break-word;border-collapse: collapse !important;vertical-align: top'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color: #212121;"><![endif]-->
<div class='u-row-container' style='padding: 0px;background-color: transparent'>
<div
class='u-row'
style='margin: 0 auto;min-width: 320px;max-width: 500px;overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;background-color: transparent;'
>
<div
style='border-collapse: collapse;display: table;width: 100%;height: 100%;background-color: transparent;'
>
<!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding: 0px;background-color: transparent;" align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:500px;"><tr style="background-color: transparent;"><![endif]-->
<!--[if (mso)|(IE)]><td align="center" width="500" style="background-color: #212121;width: 500px;padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;" valign="top"><![endif]-->
<div
class='u-col u-col-100'
style='max-width: 320px;min-width: 500px;display: table-cell;vertical-align: top;'
>
<div
style='background-color: #212121;height: 100%;width: 100% !important;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--[if (!mso)&(!IE)]><!-->
<div
style='box-sizing: border-box; height: 100%; padding: 0px;border-top: 0px solid transparent;border-left: 0px solid transparent;border-right: 0px solid transparent;border-bottom: 0px solid transparent;border-radius: 0px;-webkit-border-radius: 0px; -moz-border-radius: 0px;'
>
<!--<![endif]-->
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><table width="100%"><tr><td><![endif]-->
<h1
style='margin: 0px; line-height: 140%; text-align: left; word-wrap: break-word; font-size: 22px; font-weight: 700;'
>
<div>
<div>Welcome to {{appName}}!</div>
</div>
</h1>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Dear {{name}},</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>Thank you for registering with
{{appName}}. To complete your registration and verify your
email address, please click the button below:</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
<div align='left'>
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{verificationLink}}" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
<a
href='{{verificationLink}}'
target='_blank'
class='v-button'
style='box-sizing: border-box;display: inline-block;text-decoration: none;-webkit-text-size-adjust: none;text-align: center;color: #FFFFFF; background-color: #10a37f; border-radius: 4px;-webkit-border-radius: 4px; -moz-border-radius: 4px; width:auto; max-width:100%; overflow-wrap: break-word; word-break: break-word; word-wrap:break-word; mso-border-alt: none;font-size: 14px;'
>
<span style='display:block;padding:10px 20px;line-height:120%;'>
<div>
<div>Verify Email</div>
</div>
</span>
</a>
<!--[if mso]></center></v:roundrect><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>
<div>If you did not create an account with
{{appName}}, please ignore this email.</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: left; word-wrap: break-word;'
>
<div>Best regards,</div>
<div>The {{appName}} Team</div>
</div>
</td>
</tr>
</tbody>
</table>
<table
style='font-family:arial,helvetica,sans-serif;'
role='presentation'
cellpadding='0'
cellspacing='0'
width='100%'
border='0'
>
<tbody>
<tr>
<td
style='overflow-wrap:break-word;word-break:break-word;padding:0px 10px 10px;font-family:arial,helvetica,sans-serif;'
align='left'
>
<div
style='font-size: 14px; line-height: 140%; text-align: right; word-wrap: break-word;'
>
<div>
<div><sub>©
{{year}}
{{appName}}. All rights reserved.</sub></div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<!--[if (!mso)&(!IE)]><!-->
</div>
<!--<![endif]-->
</div>
</div>
<!--[if (mso)|(IE)]></td><![endif]-->
<!--[if (mso)|(IE)]></tr></table></td></tr></table><![endif]-->
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso]></div><![endif]-->
<!--[if IE]></div><![endif]-->
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
/** Static asset extensions that must 404 when missing — serving the SPA's
* index.html for them breaks strict MIME checks and poisons SW/browser caches. */
const STATIC_ASSET_EXT =
/\.(?:js|mjs|css|map|json|wasm|webmanifest|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|eot)$/i;
/**
* Creates the SPA fallback middleware: serves index.html for unmatched
* routes while returning 404 for missing static assets.
* @param {(req: import('express').Request, res: import('express').Response) => void} sendIndexHtml
*/
function createSpaFallback(sendIndexHtml) {
return (req, res) => {
if (STATIC_ASSET_EXT.test(req.path)) {
return res.status(404).end();
}
return sendIndexHtml(req, res);
};
}
module.exports = createSpaFallback;
+95
View File
@@ -0,0 +1,95 @@
const sharp = require('sharp');
/**
* Determines the file type of a buffer
* @param {Buffer} dataBuffer
* @param {boolean} [returnFileType=false] - Optional. If true, returns the file type instead of the file extension.
* @returns {Promise<string|null|import('file-type').FileTypeResult>} - Returns the file extension if found, else null
* */
const determineFileType = async (dataBuffer, returnFileType) => {
const fileType = await import('file-type');
const type = await fileType.fileTypeFromBuffer(dataBuffer);
if (returnFileType) {
return type;
}
return type ? type.ext : null; // Returns extension if found, else null
};
/**
* Get buffer metadata
* @param {Buffer} buffer
* @returns {Promise<{ bytes: number, type: string, dimensions: Record<string, number>, extension: string}>}
*/
const getBufferMetadata = async (buffer) => {
const fileType = await determineFileType(buffer, true);
const bytes = buffer.length;
let extension = fileType ? fileType.ext : 'unknown';
/** @type {Record<string, number>} */
let dimensions = {};
if (fileType && fileType.mime.startsWith('image/') && extension !== 'unknown') {
const imageMetadata = await sharp(buffer).metadata();
dimensions = {
width: imageMetadata.width,
height: imageMetadata.height,
};
}
return {
bytes,
type: fileType?.mime ?? 'unknown',
dimensions,
extension,
};
};
/**
* Removes UUID prefix from filename for clean display
* Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx__filename.ext
* @param {string} fileName - The filename to clean
* @returns {string} - The cleaned filename without UUID prefix
*/
const cleanFileName = (fileName) => {
if (!fileName) {
return fileName;
}
// Remove UUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx__
const cleaned = fileName.replace(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}__/i,
'',
);
return cleaned;
};
const encodeRFC5987ValueChars = (value) =>
encodeURIComponent(value).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
const getAsciiFilenameFallback = (fileName) => {
const fallback = fileName
.normalize('NFKD')
.replace(/[^\x20-\x7e]/g, '_')
.replace(/["\\\r\n]/g, '_');
return fallback || 'download';
};
const getContentDisposition = (fileName, disposition = 'attachment') => {
const cleanedFilename = cleanFileName(fileName) || 'download';
const asciiFallback = getAsciiFilenameFallback(cleanedFilename);
const encodedFilename = encodeRFC5987ValueChars(cleanedFilename);
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
};
module.exports = {
determineFileType,
getBufferMetadata,
cleanFileName,
getContentDisposition,
};
+31
View File
@@ -0,0 +1,31 @@
jest.mock('sharp', () => jest.fn(), { virtual: true });
const { cleanFileName, getContentDisposition } = require('./files');
describe('file utilities', () => {
describe('cleanFileName', () => {
it('removes storage UUID prefixes', () => {
expect(cleanFileName('123e4567-e89b-12d3-a456-426614174000__report.txt')).toBe('report.txt');
});
});
describe('getContentDisposition', () => {
it('adds RFC 8187 encoding for Unicode filenames', () => {
const filename = '日本語レポート.xlsx';
const header = getContentDisposition(`123e4567-e89b-12d3-a456-426614174000__${filename}`);
expect(header).toMatch(/^attachment; filename=".*"; filename\*=UTF-8''/);
expect(header).not.toContain('123e4567-e89b-12d3-a456-426614174000__');
expect(header).toContain(`filename*=UTF-8''${encodeURIComponent(filename)}`);
});
it('escapes the ASCII fallback without dropping the encoded filename', () => {
const filename = 'bad"name\r\n.txt';
const header = getContentDisposition(filename);
expect(header).toContain('filename="bad_name__.txt"');
expect(header).toContain("filename*=UTF-8''bad%22name%0D%0A.txt");
expect(header).not.toMatch(/[\r\n]/);
});
});
});
+60
View File
@@ -0,0 +1,60 @@
const { FileSources, FileContext } = require('librechat-data-provider');
/**
* Determines the appropriate file storage strategy based on file type and configuration.
*
* @param {AppConfig} appConfig - App configuration object containing fileStrategy and fileStrategies
* @param {Object} options - File context options
* @param {boolean} options.isAvatar - Whether this is an avatar upload
* @param {boolean} options.isImage - Whether this is an image upload
* @param {string} options.context - File context from FileContext enum
* @returns {string} Storage strategy to use (e.g., FileSources.local, 's3', 'azure')
*
* @example
* // Legacy single strategy
* getFileStrategy({ fileStrategy: 's3' }) // Returns 's3'
*
* @example
* // Granular strategies
* getFileStrategy(
* {
* fileStrategy: 's3',
* fileStrategies: { avatar: FileSources.local, document: 's3' }
* },
* { isAvatar: true }
* ) // Returns FileSources.local
*/
function getFileStrategy(appConfig, { isAvatar = false, isImage = false, context = null } = {}) {
// Fallback to legacy single strategy if no granular config
if (!appConfig?.fileStrategies) {
return appConfig.fileStrategy || FileSources.local; // Default to FileSources.local if undefined
}
const strategies = appConfig.fileStrategies;
const defaultStrategy = strategies.default || appConfig.fileStrategy || FileSources.local;
// Priority order for strategy selection:
// 1. Specific file type strategy
// 2. Default strategy from fileStrategies
// 3. Legacy fileStrategy
// 4. FileSources.local as final fallback
let selectedStrategy;
if (isAvatar || context === FileContext.avatar) {
selectedStrategy = strategies.avatar || defaultStrategy;
} else if (context === FileContext.skill_file) {
// Skill files: explicit skills strategy → fall back by type → default
selectedStrategy =
strategies.skills || (isImage ? strategies.image : strategies.document) || defaultStrategy;
} else if (isImage || context === FileContext.image_generation) {
selectedStrategy = strategies.image || defaultStrategy;
} else {
// All other files (documents, attachments, etc.)
selectedStrategy = strategies.document || defaultStrategy;
}
return selectedStrategy || FileSources.local; // Final fallback to FileSources.local
}
module.exports = { getFileStrategy };
+172
View File
@@ -0,0 +1,172 @@
const partialRight = require('lodash/partialRight');
const {
Capabilities,
EModelEndpoint,
isAgentsEndpoint,
isAssistantsEndpoint,
defaultRetrievalModels,
defaultAssistantsVersion,
defaultAgentCapabilities,
} = require('librechat-data-provider');
const { sendEvent, isUserProvided } = require('@librechat/api');
const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text);
const base = { message: true, initial: true };
const createOnProgress = (
{ generation = '', onProgress: _onProgress } = {
generation: '',
onProgress: null,
},
) => {
let i = 0;
let tokens = addSpaceIfNeeded(generation);
const basePayload = Object.assign({}, base, { text: tokens || '' });
const progressCallback = (chunk, { res, ...rest }) => {
basePayload.text = basePayload.text + chunk;
const payload = Object.assign({}, basePayload, rest);
sendEvent(res, payload);
if (_onProgress) {
_onProgress(payload);
}
if (i === 0) {
basePayload.initial = false;
}
i++;
};
const sendIntermediateMessage = (res, payload, extraTokens = '') => {
basePayload.text = basePayload.text + extraTokens;
const message = Object.assign({}, basePayload, payload);
sendEvent(res, message);
if (i === 0) {
basePayload.initial = false;
}
i++;
};
const onProgress = (opts) => {
return partialRight(progressCallback, opts);
};
const getPartialText = () => {
return basePayload.text;
};
return { onProgress, getPartialText, sendIntermediateMessage };
};
const handleText = async (response) => {
let { text } = response;
response.text = text;
return text;
};
const isObject = (item) => item && typeof item === 'object' && !Array.isArray(item);
const getString = (input) => (isObject(input) ? JSON.stringify(input) : input);
function formatSteps(steps) {
let output = '';
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const actionInput = getString(step.action.toolInput);
const observation = step.observation;
if (actionInput === 'N/A' || observation?.trim()?.length === 0) {
continue;
}
output += `Input: ${actionInput}\nOutput: ${getString(observation)}`;
if (steps.length > 1 && i !== steps.length - 1) {
output += '\n---\n';
}
}
return output;
}
function formatAction(action) {
const formattedAction = {
plugin: action.tool,
input: getString(action.toolInput),
thought: action.log.includes('Thought: ')
? action.log.split('\n')[0].replace('Thought: ', '')
: action.log.split('\n')[0],
};
formattedAction.thought = getString(formattedAction.thought);
if (action.tool.toLowerCase() === 'self-reflection' || formattedAction.plugin === 'N/A') {
formattedAction.inputStr = `{\n\tthought: ${formattedAction.input}${
!formattedAction.thought.includes(formattedAction.input)
? ' - ' + formattedAction.thought
: ''
}\n}`;
formattedAction.inputStr = formattedAction.inputStr.replace('N/A - ', '');
} else {
const hasThought = formattedAction.thought.length > 0;
const thought = hasThought ? `\n\tthought: ${formattedAction.thought}` : '';
formattedAction.inputStr = `{\n\tplugin: ${formattedAction.plugin}\n\tinput: ${formattedAction.input}\n${thought}}`;
}
return formattedAction;
}
/**
* Generate the configuration for a given key and base URL.
* @param {string} key
* @param {string} [baseURL]
* @param {string} [endpoint]
* @returns {boolean | { userProvide: boolean, userProvideURL?: boolean }}
*/
function generateConfig(key, baseURL, endpoint) {
if (!key) {
return false;
}
/** @type {{ userProvide: boolean, userProvideURL?: boolean }} */
const config = { userProvide: isUserProvided(key) };
if (baseURL) {
config.userProvideURL = isUserProvided(baseURL);
}
const assistants = isAssistantsEndpoint(endpoint);
const agents = isAgentsEndpoint(endpoint);
if (assistants) {
config.retrievalModels = defaultRetrievalModels;
config.capabilities = [
Capabilities.code_interpreter,
Capabilities.image_vision,
Capabilities.retrieval,
Capabilities.actions,
Capabilities.tools,
];
}
if (agents) {
config.capabilities = defaultAgentCapabilities;
}
if (assistants && endpoint === EModelEndpoint.azureAssistants) {
config.version = defaultAssistantsVersion.azureAssistants;
} else if (assistants) {
config.version = defaultAssistantsVersion.assistants;
}
return config;
}
module.exports = {
handleText,
formatSteps,
formatAction,
generateConfig,
addSpaceIfNeeded,
createOnProgress,
};
@@ -0,0 +1,98 @@
{
"version": 4,
"history": [
{
"id": "493cb218-9ebb-4d35-af8b-5aded3f351be",
"name": "Hello what are you able to do?",
"messages": [
{
"role": "user",
"type": "text",
"content": "Hello what are you able to do?"
},
{
"role": "assistant",
"type": "text",
"content": "Hello! As an AI developed by OpenAI, I'm designed to assist with a wide range of tasks, including but not limited to:\n\n1. **Answering Questions**: I can provide explanations on various topics from science to history, technology, and more.\n2. **Providing Information**: Whether you need the latest knowledge (up to my last training data), tips, statistics, or how-to guides, I can help.\n3. **Learning and Education**: I can assist with learning new skills, languages, or subjects.\n4. **Writing and Editing**: From composing texts to editing them, I can assist in creating content such as essays, emails, and creative writing.\n5. **Programming Help**: Guidance in understanding programming concepts, debugging code, or learning about algorithms.\n6. **Entertainment**: I can tell jokes, stories, or even generate ideas for games and activities.\n7. **Brainstorming**: Whether it's ideas for a birthday party or strategies for a marketing plan, I can help brainstorm.\n8. **Conversations**: I can have in-depth or casual discussions on a broad range of topics.\n9. **Mental Health Support**: While I'm not a substitute for professional help, I can lend an ear and offer some general advice and support.\n10. **Travel Information**: Advice on destinations, tips for travel, and information about different cultures.\n\nPlease note that my responses are based on patterns and knowledge from text up to my last update, so I might not be aware of the very latest data or events."
},
{
"role": "user",
"type": "text",
"content": "I like Entertainment part. Go ahead. Entertain me."
},
{
"role": "assistant",
"type": "text",
"content": "Certainly! Let's begin with a light-hearted joke:\n\nWhy don't skeletons fight each other?\n\nThey don't have the guts.\n\nIf you're in the mood for a story instead, let's start with a short tale:\n\n---\n\n**Title: The Lost Key**\n\nIn a quiet town nestled between rolling green hills and a silver-sheened lake, there lived a locksmith named Eli. Eli had a peculiar ability; he could craft keys that didn't just unlock doors but also opened pathways to hidden places and secret realms. However, Eli never ventured through these magical doors; he was content knowing that he had the power to unveil them.\n\nOne day, Eli crafted his masterpiece—a key forged from an unknown, shimmering metal that seemed to hum with an inner light. The locksmith felt a deep connection to this key, as if it were calling to him to seek the door it was destined to open. Despite his usual reservations, curiosity got the better of him, and he embarked on a journey to find the matching lock.\n\nHe traveled through the town, greeting familiar faces and searching every nook and cranny, but to no avail. Eli was about to give up when he noticed a peculiar cat with a coat as dark as the night sky, its eyes glinting with a hint of stars. The cat seemed to beckon him to follow.\n\nLed by the cosmic feline through alleys and gardens, Eli arrived at a forgotten part of town where an old, vine-covered wall stood. To his astonishment, in the wall was a door that rippled like the surface of the lake when touched by a soft breeze. It was a door he had never seen before. Trembling with excitement, Eli took out the key. It fit perfectly into the lock, which turned with a satisfying click.\n\nAs the door creaked open, a brilliant light spilled forth. Eli shielded his eyes, and when he finally could see, he found himself not in another realm, but in the heart of his own town. However, everything appeared different—colors were more vibrant, sounds were clearer, and the air carried the sweet scent of blooming flowers.\n\nThe town's people approached him, smiling. \"We've been waiting for you,\" they said. \"The key wasn't meant to lead you to another world, but to allow you to see your own world differently. To show you that magic exists here, with all of us.\"\n\nEli's life transformed that day. He realized that every smile, every act of kindness, and every moment of connection was a door to a world just as magical as those he could unlock with his keys.\n\nAnd as for the mysterious cosmic cat, it would appear now and then, always at the edge of his vision, reminding him that the real magic was here all along, hidden in plain sight.\n\n---\n\nI hope you enjoyed the joke and the short story. If you'd like to continue the entertainment, feel free to guide me on what you'd like to hear or discuss next!"
}
],
"model": {
"id": "gpt-4-1106-preview",
"name": "GPT-4 Turbo Preview",
"maxLength": 384000,
"tokenLimit": 128000
},
"prompt": "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.",
"temperature": 1,
"folderId": null
},
{
"id": "f883fb40-6e5c-45fd-92d4-883a5595560f",
"name": "Give me the code that inverts ...",
"messages": [
{
"role": "user",
"type": "text",
"content": "Give me the code that inverts binary tree in COBOL"
},
{
"role": "assistant",
"type": "text",
"content": "COBOL is not a language that is commonly used for tasks like inverting a binary tree. It is more suited for business applications and mainframe programming. However, if you are looking for a general idea of how you might approach this in COBOL, here is a simplified example:\n\n```cobol\nIDENTIFICATION DIVISION.\nPROGRAM-ID. InvertBinaryTree.\n\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 Node.\n 05 Left-Child PIC 9.\n 05 Right-Child PIC 9.\n\nPROCEDURE DIVISION.\nInvert-Binary-Tree.\n MOVE 1 TO Left-Child\n MOVE 2 TO Right-Child\n\n DISPLAY \"Original Binary Tree:\"\n DISPLAY \"Left Child: \" Left-Child\n DISPLAY \"Right Child: \" Right-Child\n\n PERFORM Swap-Children\n\n DISPLAY \"Inverted Binary Tree:\"\n DISPLAY \"Left Child: \" Left-Child\n DISPLAY \"Right Child: \" Right-Child\n\n STOP RUN.\n\nSwap-Children.\n MOVE Left-Child TO Node\n MOVE Right-Child TO Left-Child\n MOVE Node TO Right-Child\n```\n\nThis is a very basic example and may not cover all the complexities of inverting a binary tree. You would need to adapt and expand this code significantly for a more complex binary tree structure."
}
],
"model": {
"id": "gpt-3.5-turbo",
"name": "GPT-3.5"
},
"prompt": "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.",
"temperature": 0.5,
"folderId": null
}
],
"folders": [
{
"id": "cdc857de-e669-498d-8fac-edc4995c9d7a",
"name": "New folder",
"type": "prompt"
}
],
"prompts": [
{
"id": "a61573d8-6686-487c-9c5d-cd79c6d201ee",
"name": "Prompt 1",
"description": "",
"content": "",
"model": {
"id": "gpt-4",
"name": "GPT-4",
"maxLength": 24000,
"tokenLimit": 8000
},
"folderId": null
},
{
"id": "9bf456e3-61fc-494d-b940-55ec934e7a04",
"name": "Prompt 2",
"description": "afgdfsg",
"content": "adfdsfsadf",
"model": {
"id": "gpt-4",
"name": "GPT-4",
"maxLength": 24000,
"tokenLimit": 8000
},
"folderId": null
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,429 @@
[
{
"title": "Assist user with summary",
"create_time": 1714585031.148505,
"update_time": 1714585060.879308,
"mapping": {
"d38605d2-7b2c-43de-b044-22ce472c749b": {
"id": "d38605d2-7b2c-43de-b044-22ce472c749b",
"message": {
"id": "d38605d2-7b2c-43de-b044-22ce472c749b",
"author": {
"role": "system",
"name": null,
"metadata": {}
},
"create_time": null,
"update_time": null,
"content": {
"content_type": "text",
"parts": [""]
},
"status": "finished_successfully",
"end_turn": true,
"weight": 0,
"metadata": {
"is_visually_hidden_from_conversation": true
},
"recipient": "all"
},
"parent": "aaa1f70c-100e-46f0-999e-10c8565f047f",
"children": ["aaa297ba-e2da-440e-84f4-e62e7be8b003"]
},
"aaa1f70c-100e-46f0-999e-10c8565f047f": {
"id": "aaa1f70c-100e-46f0-999e-10c8565f047f",
"message": null,
"parent": null,
"children": ["d38605d2-7b2c-43de-b044-22ce472c749b"]
},
"aaa297ba-e2da-440e-84f4-e62e7be8b003": {
"id": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
"message": {
"id": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
"author": {
"role": "user",
"name": null,
"metadata": {}
},
"create_time": 1714585031.150442,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["hi there"]
},
"status": "finished_successfully",
"end_turn": null,
"weight": 1,
"metadata": {
"request_id": "87d189bb49d412c5-IAD",
"timestamp_": "absolute",
"message_type": null
},
"recipient": "all"
},
"parent": "d38605d2-7b2c-43de-b044-22ce472c749b",
"children": ["bda8a275-886d-4f59-b38c-d7037144f0d5"]
},
"bda8a275-886d-4f59-b38c-d7037144f0d5": {
"id": "bda8a275-886d-4f59-b38c-d7037144f0d5",
"message": {
"id": "bda8a275-886d-4f59-b38c-d7037144f0d5",
"author": {
"role": "assistant",
"name": null,
"metadata": {}
},
"create_time": 1714585031.757056,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["Hello! How can I assist you today?"]
},
"status": "finished_successfully",
"end_turn": true,
"weight": 1,
"metadata": {
"finish_details": {
"type": "stop",
"stop_tokens": [100260]
},
"citations": [],
"gizmo_id": null,
"message_type": null,
"model_slug": "text-davinci-002-render-sha",
"default_model_slug": "text-davinci-002-render-sha",
"pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
"parent_id": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
"is_complete": true,
"request_id": "87d189bb49d412c5-IAD",
"timestamp_": "absolute"
},
"recipient": "all"
},
"parent": "aaa297ba-e2da-440e-84f4-e62e7be8b003",
"children": ["aaa24023-b02f-4d49-b568-5856b41750c0", "aaa236a3-cdfc-4eb1-b5c5-790c6641f880"]
},
"aaa24023-b02f-4d49-b568-5856b41750c0": {
"id": "aaa24023-b02f-4d49-b568-5856b41750c0",
"message": {
"id": "aaa24023-b02f-4d49-b568-5856b41750c0",
"author": {
"role": "user",
"name": null,
"metadata": {}
},
"create_time": 1714585034.306995,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["so cool bro"]
},
"status": "finished_successfully",
"end_turn": null,
"weight": 1,
"metadata": {
"request_id": "87d189cf3df512c5-IAD",
"timestamp_": "absolute",
"message_type": null
},
"recipient": "all"
},
"parent": "bda8a275-886d-4f59-b38c-d7037144f0d5",
"children": ["23afbea9-ca08-49f2-b417-e7ae58a1c97d"]
},
"23afbea9-ca08-49f2-b417-e7ae58a1c97d": {
"id": "23afbea9-ca08-49f2-b417-e7ae58a1c97d",
"message": {
"id": "23afbea9-ca08-49f2-b417-e7ae58a1c97d",
"author": {
"role": "assistant",
"name": null,
"metadata": {}
},
"create_time": 1714585034.755907,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["Thanks! What brings you here today?"]
},
"status": "finished_successfully",
"end_turn": true,
"weight": 1,
"metadata": {
"finish_details": {
"type": "stop",
"stop_tokens": [100260]
},
"citations": [],
"gizmo_id": null,
"is_complete": true,
"message_type": null,
"model_slug": "text-davinci-002-render-sha",
"default_model_slug": "text-davinci-002-render-sha",
"pad": "AAAAAAAAAAAAAAAAAAAAAAAAA",
"parent_id": "aaa24023-b02f-4d49-b568-5856b41750c0",
"request_id": "87d189cf3df512c5-IAD",
"timestamp_": "absolute"
},
"recipient": "all"
},
"parent": "aaa24023-b02f-4d49-b568-5856b41750c0",
"children": ["aaa292cc-1842-4dbf-bd79-13cf7150366a"]
},
"aaa292cc-1842-4dbf-bd79-13cf7150366a": {
"id": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
"message": {
"id": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
"author": {
"role": "user",
"name": null,
"metadata": {}
},
"create_time": 1714585037.56986,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["tell me a story"]
},
"status": "finished_successfully",
"end_turn": null,
"weight": 1,
"metadata": {
"request_id": "87d189e3dac712c5-IAD",
"timestamp_": "absolute",
"message_type": null
},
"recipient": "all"
},
"parent": "23afbea9-ca08-49f2-b417-e7ae58a1c97d",
"children": ["ada93f81-f59e-4b31-933d-1357efd68bfc"]
},
"ada93f81-f59e-4b31-933d-1357efd68bfc": {
"id": "ada93f81-f59e-4b31-933d-1357efd68bfc",
"message": {
"id": "ada93f81-f59e-4b31-933d-1357efd68bfc",
"author": {
"role": "assistant",
"name": null,
"metadata": {}
},
"create_time": 1714585045.606752,
"update_time": null,
"content": {
"content_type": "text",
"parts": [
"Sure! Here's a short story for you:\n\n---\n\nOnce upon a time, in a small village nestled between rolling"
]
},
"status": "in_progress",
"end_turn": null,
"weight": 1,
"metadata": {
"citations": [],
"gizmo_id": null,
"message_type": null,
"model_slug": "text-davinci-002-render-sha",
"default_model_slug": "text-davinci-002-render-sha",
"pad": "AAAAAAAAAAAAAAAAAA",
"parent_id": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
"finish_details": {
"type": "interrupted"
},
"request_id": "87d189e3dac712c5-IAD",
"timestamp_": "absolute"
},
"recipient": "all"
},
"parent": "aaa292cc-1842-4dbf-bd79-13cf7150366a",
"children": []
},
"aaa236a3-cdfc-4eb1-b5c5-790c6641f880": {
"id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
"message": {
"id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
"author": {
"role": "user",
"name": null,
"metadata": {}
},
"create_time": 1714585050.906034,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["hi again"]
},
"status": "finished_successfully",
"end_turn": null,
"weight": 1,
"metadata": {
"request_id": "87d18a36cf9312c5-IAD",
"timestamp_": "absolute",
"message_type": null
},
"recipient": "all"
},
"parent": "bda8a275-886d-4f59-b38c-d7037144f0d5",
"children": ["db88eddf-3622-4246-8527-b6eaf0e9e8cd"]
},
"db88eddf-3622-4246-8527-b6eaf0e9e8cd": {
"id": "db88eddf-3622-4246-8527-b6eaf0e9e8cd",
"message": {
"id": "db88eddf-3622-4246-8527-b6eaf0e9e8cd",
"author": {
"role": "assistant",
"name": null,
"metadata": {}
},
"create_time": 1714585051.690729,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["Hey! Welcome back. What's on your mind?"]
},
"status": "finished_successfully",
"end_turn": true,
"weight": 1,
"metadata": {
"finish_details": {
"type": "stop",
"stop_tokens": [100260]
},
"citations": [],
"gizmo_id": null,
"is_complete": true,
"message_type": null,
"model_slug": "text-davinci-002-render-sha",
"default_model_slug": "text-davinci-002-render-sha",
"pad": "AAAAAAAAAAAAAAAAAAAAA",
"parent_id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
"request_id": "87d18a36cf9312c5-IAD",
"timestamp_": "absolute"
},
"recipient": "all"
},
"parent": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880",
"children": ["aaa20127-b9e3-44f6-afbe-a2475838625a"]
},
"aaa20127-b9e3-44f6-afbe-a2475838625a": {
"id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
"message": {
"id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
"author": {
"role": "user",
"name": null,
"metadata": {}
},
"create_time": 1714585055.908847,
"update_time": null,
"content": {
"content_type": "text",
"parts": ["tell me a joke"]
},
"status": "finished_successfully",
"end_turn": null,
"weight": 1,
"metadata": {
"request_id": "87d18a6e39a312c5-IAD",
"timestamp_": "absolute",
"message_type": null
},
"recipient": "all"
},
"parent": "db88eddf-3622-4246-8527-b6eaf0e9e8cd",
"children": ["d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae", "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8"]
},
"d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae": {
"id": "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae",
"message": {
"id": "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae",
"author": {
"role": "assistant",
"name": null,
"metadata": {}
},
"create_time": 1714585056.580956,
"update_time": null,
"content": {
"content_type": "text",
"parts": [
"Sure, here's one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!"
]
},
"status": "finished_successfully",
"end_turn": true,
"weight": 1,
"metadata": {
"finish_details": {
"type": "stop",
"stop_tokens": [100260]
},
"citations": [],
"gizmo_id": null,
"message_type": null,
"model_slug": "text-davinci-002-render-sha",
"default_model_slug": "text-davinci-002-render-sha",
"pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
"parent_id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
"is_complete": true,
"request_id": "87d18a55ca6212c5-IAD",
"timestamp_": "absolute"
},
"recipient": "all"
},
"parent": "aaa20127-b9e3-44f6-afbe-a2475838625a",
"children": []
},
"f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8": {
"id": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8",
"message": {
"id": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8",
"author": {
"role": "assistant",
"name": null,
"metadata": {}
},
"create_time": 1714585060.598792,
"update_time": null,
"content": {
"content_type": "text",
"parts": [
"Sure, here's one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!"
]
},
"status": "finished_successfully",
"end_turn": true,
"weight": 1,
"metadata": {
"finish_details": {
"type": "stop",
"stop_tokens": [100260]
},
"citations": [],
"gizmo_id": null,
"is_complete": true,
"message_type": null,
"model_slug": "text-davinci-002-render-sha",
"default_model_slug": "text-davinci-002-render-sha",
"pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
"parent_id": "aaa20127-b9e3-44f6-afbe-a2475838625a",
"request_id": "87d18a6e39a312c5-IAD",
"timestamp_": "absolute"
},
"recipient": "all"
},
"parent": "aaa20127-b9e3-44f6-afbe-a2475838625a",
"children": []
}
},
"moderation_results": [],
"current_node": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8",
"plugin_ids": null,
"conversation_id": "d5dc5307-6807-41a0-8b04-4acee626eeb7",
"conversation_template_id": null,
"gizmo_id": null,
"is_archived": false,
"safe_urls": [],
"default_model_slug": "text-davinci-002-render-sha",
"id": "d5dc5307-6807-41a0-8b04-4acee626eeb7"
}
]
@@ -0,0 +1,137 @@
{
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"endpoint": "openAI",
"title": "Conversation 1. Web Search",
"exportAt": "16:33:32 GMT+0200 (Central European Summer Time)",
"branches": true,
"recursive": true,
"options": {
"presetId": null,
"model": "gpt-3.5-turbo",
"chatGptLabel": null,
"promptPrefix": null,
"temperature": 1,
"top_p": 1,
"presence_penalty": 0,
"frequency_penalty": 0,
"resendFiles": true,
"imageDetail": "auto",
"endpoint": "openAI",
"title": "VW Transporter 2014 Fuel Consumption. Web Search"
},
"messages": [
{
"_id": "6615516574dc2ddcdebe40b6",
"messageId": "b123942f-ca1a-4b16-9e1f-ea4af5171168",
"__v": 0,
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"createdAt": "2024-04-09T14:32:05.230Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": true,
"model": null,
"parentMessageId": "00000000-0000-0000-0000-000000000000",
"sender": "user",
"text": "What is the fuel consumption of vw transporter with 8 people in l/km",
"unfinished": false,
"updatedAt": "2024-04-09T14:32:05.230Z",
"user": "65f1ad8c90523874d2d409f8",
"children": [
{
"_id": "6615516574dc2ddcdebe40b8",
"messageId": "549a4f45-cf93-4e3b-ae62-1abf02afbfc8",
"__v": 0,
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"createdAt": "2024-04-09T14:32:05.242Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": false,
"model": null,
"parentMessageId": "b123942f-ca1a-4b16-9e1f-ea4af5171168",
"sender": "GPT-3.5",
"text": "The fuel consumption of a Volkswagen Transporter can vary based on the specific model and driving conditions. For example, the 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP, which is one of the recent models, doesn't have its fuel consumption data explicitly mentioned in the sources I found. However, a review of a Volkswagen Transporter model, which is equipped with a 2.0-litre four-cylinder turbo diesel engine and a seven-speed dual-clutch automatic transmission, mentions a combined cycle fuel consumption figure of 8.2 litres per 100km. It's important to note that this figure might vary, especially when the vehicle is carrying eight people, as more weight can lead to increased fuel consumption.\n\nFor more detailed information, you might need to refer to the specific model's manual or contact a Volkswagen dealer. Also, keep in mind that real-world fuel consumption can be influenced by factors such as driving style, road conditions, and maintenance of the vehicle ([2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions](https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html)) ([Volkswagen Transporter Review - Drive](https://www.drive.com.au/reviews/volkswagen-transporter-review/)).",
"unfinished": false,
"updatedAt": "2024-04-09T14:32:05.242Z",
"user": "65f1ad8c90523874d2d409f8",
"children": [
{
"_id": "6615516574dc2ddcdebe40ba",
"messageId": "880e5357-3e0c-4218-b351-fd3fc184adef",
"__v": 0,
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"createdAt": "2024-04-09T14:32:05.247Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": true,
"model": null,
"parentMessageId": "549a4f45-cf93-4e3b-ae62-1abf02afbfc8",
"sender": "user",
"text": "What about 10 year old model",
"unfinished": false,
"updatedAt": "2024-04-09T14:32:05.247Z",
"user": "65f1ad8c90523874d2d409f8",
"children": [
{
"_id": "6615516574dc2ddcdebe40bc",
"messageId": "e9796d11-3bdf-4e25-9f0e-4802bbbb8c6d",
"__v": 0,
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"createdAt": "2024-04-09T14:32:05.256Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": false,
"model": null,
"parentMessageId": "880e5357-3e0c-4218-b351-fd3fc184adef",
"sender": "GPT-3.5",
"text": "The fuel consumption of a 2014 Volkswagen Transporter, specifically the TDI340 model with a 2.0 L engine, is reported as follows:\n- Combined: 8.2 L/100km\n- Highway: 6.9 L/100km\n- City: 10.2 L/100km\n\nThese figures can vary based on driving conditions and the vehicle's load. The actual consumption might be higher when carrying eight people due to the additional weight ([Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au](https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/)).",
"unfinished": false,
"updatedAt": "2024-04-09T14:32:05.256Z",
"user": "65f1ad8c90523874d2d409f8",
"children": [
{
"_id": "6615516574dc2ddcdebe40be",
"messageId": "04408c06-62dc-4961-8ef5-4336b68e7a0a",
"__v": 0,
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"createdAt": "2024-04-09T14:32:05.261Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": true,
"model": null,
"parentMessageId": "e9796d11-3bdf-4e25-9f0e-4802bbbb8c6d",
"sender": "user",
"text": "Is it for fully loaded car?",
"unfinished": false,
"updatedAt": "2024-04-09T14:32:05.261Z",
"user": "65f1ad8c90523874d2d409f8",
"children": [
{
"_id": "6615516574dc2ddcdebe40c0",
"messageId": "645b925b-ef14-451f-9be3-814de55a5ae4",
"__v": 0,
"conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733",
"createdAt": "2024-04-09T14:32:05.266Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": false,
"model": null,
"parentMessageId": "04408c06-62dc-4961-8ef5-4336b68e7a0a",
"sender": "GPT-3.5",
"text": "The fuel consumption figures for the 2014 Volkswagen Transporter TDI340, such as the combined 8.2 L/100km, are typically based on standardized tests. These tests don't necessarily reflect a fully loaded vehicle. The actual fuel consumption can vary and is likely to be higher when the vehicle is fully loaded with passengers or cargo, due to the increased weight and potential changes in driving conditions such as more frequent acceleration and deceleration ([Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au](https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/)).",
"unfinished": false,
"updatedAt": "2024-04-09T14:32:05.266Z",
"user": "65f1ad8c90523874d2d409f8",
"children": []
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,40 @@
{
"conversationId": "4c9e1402-f746-479d-8a18-2dfab1bf750f",
"endpoint": "azureOpenAI",
"title": "Adventure Across Time and Space",
"exportAt": "20:33:31 GMT-0400 (Eastern Daylight Time)",
"branches": false,
"recursive": false,
"messages": [
{
"messageId": "ed3fdaaa-e174-4d70-89a9-b06e70939723",
"conversationId": "4c9e1402-f746-479d-8a18-2dfab1bf750f",
"createdAt": "2024-05-29T00:33:03.661Z",
"endpoint": "azureOpenAI",
"error": false,
"isCreatedByUser": true,
"isEdited": false,
"model": null,
"parentMessageId": null,
"sender": "user",
"text": "tell me a long story",
"unfinished": false,
"updatedAt": "2024-05-29T00:33:03.661Z"
},
{
"messageId": "113b8336-4b19-4f91-8eed-741b3dc2940e",
"conversationId": "4c9e1402-f746-479d-8a18-2dfab1bf750f",
"createdAt": "2024-05-29T00:33:03.661Z",
"endpoint": "azureOpenAI",
"error": false,
"isCreatedByUser": false,
"isEdited": false,
"model": "gpt-4o",
"parentMessageId": "ed3fdaaa-e174-4d70-89a9-b06e70939723",
"sender": "GPT-4",
"text": "Of course! Heres a detailed and engaging story:\n\n---\n\n### The Legend of Eldoria\n\nNestled between towering mountains and dense, ancient forests was the enigmatic kingdom of Eldoria. This realm, clo aked in perpetual twilight, was the stuff of legends. It was said that the land was blessed by the gods and guarded by mythical creatures. Eldoria was a place where magic and realism intertwined seamlessly, creating a land of beauty, wonder, and peril.\n\nIn the heart of this kingdom lay the grand city of Lumina, known",
"unfinished": false,
"updatedAt": "2024-05-29T00:33:03.661Z"
}
]
}
@@ -0,0 +1,99 @@
{
"conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
"endpoint": "azureOpenAI",
"title": "Original",
"exportAt": "08:12:16 GMT-0400 (Eastern Daylight Time)",
"branches": true,
"recursive": false,
"options": {
"presetId": null,
"model": "gpt-4o",
"chatGptLabel": null,
"promptPrefix": null,
"temperature": 1,
"top_p": 1,
"presence_penalty": 0,
"frequency_penalty": 0,
"resendFiles": true,
"imageDetail": "auto",
"endpoint": "azureOpenAI",
"title": "Original"
},
"messages": [
{
"messageId": "115a6247-8fb0-4937-a536-12956669098d",
"conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
"createdAt": "2024-05-28T18:08:55.014Z",
"endpoint": "azureOpenAI",
"error": false,
"isCreatedByUser": true,
"model": null,
"parentMessageId": "00000000-0000-0000-0000-000000000000",
"sender": "User",
"text": "tell me a long story",
"tokenCount": 9,
"unfinished": false,
"updatedAt": "2024-05-28T18:09:27.193Z"
},
{
"messageId": "069b9c22-7649-45a9-b90b-fc050533ea21",
"conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
"createdAt": "2024-05-28T18:08:55.390Z",
"error": false,
"isCreatedByUser": false,
"model": "gpt-4o",
"parentMessageId": "115a6247-8fb0-4937-a536-12956669098d",
"sender": "GPT-4",
"text": "Of course! Settle in for a tale of adventure across time and space.\n\n---\n\nOnce upon a time in the small, sleepy village of Eldoria, there was a young woman named Elara who longed for adventure. Eldoria was a place of routine and simplicity, nestled between rolling hills and dense forests, but Elara always felt that there was more to the world than the boundaries",
"unfinished": false,
"updatedAt": "2024-05-28T18:08:58.669Z",
"endpoint": "azureOpenAI",
"tokenCount": 78,
"finish_reason": "incomplete"
},
{
"messageId": "303e4c2c-f03e-4e0a-8551-c96ec73be5fe",
"conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
"createdAt": "2024-05-28T18:09:27.444Z",
"error": false,
"isCreatedByUser": false,
"model": "gpt-4o",
"parentMessageId": "115a6247-8fb0-4937-a536-12956669098d",
"sender": "GPT-4",
"text": "Sure, I can craft a long story for you. Here it goes:\n\n### The Chronicles of Elenor: The Luminary of Anduril\n\nIn an age long forgotten by men, in a world kissed by the glow of dual suns, the Kingdom of Anduril flourished. Verdant valleys graced its land, majestic mountains shielded",
"unfinished": true,
"updatedAt": "2024-05-28T18:09:30.448Z"
},
{
"messageId": "599e1908-8c52-4a73-ba6b-f6dffbd79ba0",
"conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
"createdAt": "2024-05-28T18:14:07.988Z",
"endpoint": "azureOpenAI",
"error": false,
"isCreatedByUser": true,
"model": null,
"parentMessageId": "00000000-0000-0000-0000-000000000000",
"sender": "User",
"text": "tell me a long long story",
"tokenCount": 9,
"unfinished": false,
"updatedAt": "2024-05-28T18:14:07.988Z"
},
{
"messageId": "de9a4e7c-020d-4856-a5a6-ce6794efef99",
"conversationId": "27b593be-9500-479c-94cb-050cab8f5033",
"createdAt": "2024-05-28T18:14:08.403Z",
"error": false,
"isCreatedByUser": false,
"model": "gpt-4o",
"parentMessageId": "599e1908-8c52-4a73-ba6b-f6dffbd79ba0",
"sender": "GPT-4",
"text": "Of course! Heres a detailed and engaging story:\n\n---\n\n### The Legend of Eldoria\n\nNestled between towering mountains and dense, ancient forests was the enigmatic kingdom of Eldoria. This realm, clo aked in perpetual twilight, was the stuff of legends. It was said that the land was blessed by the gods and guarded by mythical creatures. Eldoria was a place where magic and realism intertwined seamlessly, creating a land of beauty, wonder, and peril.\n\nIn the heart of this kingdom lay the grand city of Lumina, known",
"unfinished": false,
"updatedAt": "2024-05-28T18:14:20.349Z",
"endpoint": "azureOpenAI",
"finish_reason": "incomplete",
"tokenCount": 110
}
]
}
@@ -0,0 +1,151 @@
{
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"endpoint": "openAI",
"title": "Troubleshooting Python Virtual Environment Activation Issue",
"exportAt": "13:18:04 GMT-0400 (Eastern Daylight Time)",
"branches": true,
"recursive": true,
"options": {
"presetId": null,
"model": "gpt-4-turbo",
"chatGptLabel": "Software Engineer",
"promptPrefix": "You are an expert software engineer.",
"temperature": 0.8,
"top_p": 1,
"presence_penalty": 0,
"frequency_penalty": 0,
"resendFiles": true,
"imageDetail": "auto",
"endpoint": "openAI",
"title": "Troubleshooting Python Virtual Environment Activation Issue"
},
"messages": [
{
"_id": "66326f3f04bed94b7f5be68d",
"messageId": "9501f99d-9bbb-40cb-bbb2-16d79aeceb72",
"__v": 0,
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"createdAt": "2024-05-01T16:35:11.530Z",
"endpoint": "openAI",
"error": false,
"isCreatedByUser": true,
"model": null,
"parentMessageId": "00000000-0000-0000-0000-000000000000",
"sender": "User",
"text": "How do I use source venv/bin/activate?\nbash: venv/bin/activate: No such file or directory",
"tokenCount": 59,
"unfinished": false,
"updatedAt": "2024-05-01T16:35:39.114Z",
"user": "64d4f005d73ef522e1f48818",
"children": [
{
"_id": "66326f4004bed94b7f5be699",
"messageId": "4c395510-fdfd-4556-b034-f9440f025e91",
"__v": 0,
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"createdAt": "2024-05-01T16:35:12.604Z",
"error": false,
"isCreatedByUser": false,
"model": "gpt-4-turbo",
"parentMessageId": "9501f99d-9bbb-40cb-bbb2-16d79aeceb72",
"sender": "Software Engineer",
"text": "It looks like you're trying to activate a Python virtual environment but encountering an issue because the system cannot find the `activate` script in the `venv/bin/` directory. This typically happens if the virtual environment has not been created yet or if the path specified is incorrect. Here are a few steps to troubleshoot and resolve this issue:\n\n1. **Check if the Virtual Environment Exists**:\n Ensure that the virtual environment directory (`venv`) exists in your current directory (`~/denis-libre`). You can check this by listing the directories:\n\n ```bash\n ls -l\n ```\n\n Look for `venv` in the output. If it's not there, you'll need to create it.\n\n2. **Create the Virtual Environment**:\n If the virtual environment does not exist, you can create one by using the following command:\n\n ```bash\n python3 -m venv venv\n ```\n\n This command creates a new virtual environment named `venv` in your current directory.\n\n3. **Activate the Virtual Environment**:\n Once you have confirmed that the `venv` directory exists, try activating the virtual environment again:\n\n ```bash\n source venv/bin/activate\n ```\n\n If you still receive an error, double-check the path. If you are on a Windows system using Git Bash or similar, the path might be `venv\\Scripts\\activate` instead.\n\n4. **Check for Environment Specific Issues**:\n - If you are using a different shell (like `csh` or `fish`), the activation script could be different (e.g., `activate.csh` or `activate.fish`).\n - Ensure theres no typo in your command or path misconfiguration.\n\n5. **Permissions Issue**:\n Make sure you have the necessary permissions to access the `venv` directory and its contents. If not, you might need to modify the permissions:\n\n ```bash\n chmod -R 755 venv\n ```\n\n6. **Using the Correct Python Version**:\n Confirm that you are using the correct version of Python for which the virtual environment is supposed to be set up. Sometimes, using a different version might cause issues.\n\nBy following these steps, you should be able to resolve the issue with activating your Python virtual environment. If the problem persists, there might be a more specific issue with your Python installation or system configuration.",
"unfinished": false,
"updatedAt": "2024-05-01T16:35:39.104Z",
"user": "64d4f005d73ef522e1f48818",
"endpoint": "openAI",
"finish_reason": "stop",
"tokenCount": 504,
"children": [
{
"messageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270",
"parentMessageId": "4c395510-fdfd-4556-b034-f9440f025e91",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"sender": "User",
"text": "thanks",
"isCreatedByUser": true,
"tokenCount": 5,
"children": [
{
"messageId": "5da2cdce-87f7-46b4-87c5-e8224e5d5125",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"parentMessageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270",
"isCreatedByUser": false,
"model": "gpt-4-turbo",
"sender": "Software Engineer",
"text": "You're welcome! If you have any more questions or need further assistance, feel free to ask. Good luck with your project!",
"promptTokens": 586,
"endpoint": "openAI",
"finish_reason": "stop",
"children": []
},
{
"messageId": "64ed7568-bdcf-42a9-b34a-73386d4d99dc",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"parentMessageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270",
"isCreatedByUser": false,
"model": "gpt-4-turbo",
"sender": "Software Engineer",
"text": "You're welcome! If you have any more questions or need further assistance, feel free to ask. Happy coding!",
"promptTokens": 586,
"endpoint": "openAI",
"finish_reason": "stop",
"children": [
{
"messageId": "f82cca6f-7d2b-4fc1-9d5a-636acc137368",
"parentMessageId": "64ed7568-bdcf-42a9-b34a-73386d4d99dc",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"sender": "User",
"text": "does coding make you happy",
"isCreatedByUser": true,
"tokenCount": 9,
"children": [
{
"messageId": "ad7b6ec6-aaa4-434c-9188-def6aadbb2bf",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"parentMessageId": "f82cca6f-7d2b-4fc1-9d5a-636acc137368",
"isCreatedByUser": false,
"model": "gpt-4-turbo",
"sender": "Software Engineer",
"text": "As a software engineer, coding can indeed be a source of happiness and satisfaction for many professionals in the field. Heres why coding might make someone happy:\n\n1. **Problem Solving**: Coding offers a constant stream of challenges that require problem-solving skills. Solving these problems can be immensely gratifying and can provide a sense of accomplishment.\n\n2. **Creativity**: Writing software is often a creative process, similar to composing music or painting. Developers can express themselves through their code, which can be a fulfilling experience.\n\n3. **Continuous Learning**: The tech field is always evolving, offering endless opportunities to learn new languages, tools, and techniques. For many, this continuous learning keeps the job exciting and engaging.\n\n4. **Impact**: Software engineers often work on applications that impact the lives of millions of people. Knowing that your work is helping others can be a powerful source of happiness.\n\n5. **Community**: The coding community is known for its collaboration and sharing of knowledge through platforms like GitHub, Stack Overflow, and various forums. Being a part of this community can provide a sense of belonging and support.\n\n6. **Career Opportunities**: Coding skills are in high demand, which can lead to job security, good salaries, and the opportunity to work in a variety of industries. This can contribute to overall life satisfaction and stability.\n\nHowever, like any job, coding can also be stressful at times. Deadlines, bugs, difficult problems, and other challenges can cause frustration. The key is to find a balance, continue learning, and choose projects that align with your interests and values. If these elements are in place, coding can indeed be a very happy career choice.",
"promptTokens": 622,
"endpoint": "openAI",
"finish_reason": "stop",
"children": []
}
]
}
]
}
]
},
{
"messageId": "d3e12088-962e-42ce-900d-edd6816acae5",
"parentMessageId": "4c395510-fdfd-4556-b034-f9440f025e91",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"sender": "User",
"text": "lol",
"isCreatedByUser": true,
"tokenCount": 5,
"children": [
{
"messageId": "1f062c99-ff0a-4cf4-a1cf-7150261a24e2",
"conversationId": "4a86c40e-e627-4454-b158-889680e23ad3",
"parentMessageId": "d3e12088-962e-42ce-900d-edd6816acae5",
"isCreatedByUser": false,
"model": "gpt-4-turbo",
"sender": "Software Engineer",
"text": "It looks like you might have been amused or found something funny about the situation! If you have any specific questions or need further help with your virtual environment setup or anything else related to software engineering, feel free to ask!",
"promptTokens": 586,
"endpoint": "openAI",
"finish_reason": "stop",
"children": []
}
]
}
]
}
]
}
]
}
+142
View File
@@ -0,0 +1,142 @@
const { logger, getTenantId } = require('@librechat/data-schemas');
const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider');
const { getModelsConfig } = require('~/server/controllers/ModelController');
/**
* Last-resort hardcoded defaults used only when the runtime models config is
* unavailable or returns no models for the endpoint.
*/
const FALLBACK_MODEL_BY_ENDPOINT = {
[EModelEndpoint.openAI]: openAISettings.model.default,
[EModelEndpoint.anthropic]: anthropicSettings.model.default,
};
/**
* Picks the first available model for an endpoint from a runtime models config.
*
* @param {string} endpoint - The endpoint key (e.g. EModelEndpoint.anthropic).
* @param {TModelsConfig} [modelsConfig] - Map of endpoint -> available model list.
* @returns {string | undefined} The first model for the endpoint, or undefined.
*/
function pickFirstConfiguredModel(endpoint, modelsConfig) {
const models = modelsConfig?.[endpoint];
if (!Array.isArray(models)) {
return undefined;
}
for (const model of models) {
if (typeof model === 'string' && model.length > 0) {
return model;
}
}
return undefined;
}
/**
* Resolves the default model that imported conversations should be saved with
* for a given endpoint. Prefers the first model exposed by the runtime models
* config (admin-configured / provider-discovered), and only falls back to the
* hardcoded per-endpoint default if the runtime config is empty or fails.
*
* @param {object} args
* @param {string} args.endpoint - The endpoint key the import is targeting.
* @param {string} args.requestUserId - The id of the importing user.
* @param {string} [args.userRole] - The role of the importing user.
* @returns {Promise<string>} The default model name to persist on the conversation.
*/
async function resolveImportDefaultModel({ endpoint, requestUserId, userRole }) {
try {
const modelsConfig = await getModelsConfig({
user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
});
const configured = pickFirstConfiguredModel(endpoint, modelsConfig);
if (configured) {
return configured;
}
} catch (error) {
logger.warn(
`[import] Failed to resolve default model from modelsConfig for ${endpoint}: ${error.message}`,
);
}
return FALLBACK_MODEL_BY_ENDPOINT[endpoint] ?? openAISettings.model.default;
}
/**
* Preferred endpoint order for conversations cloned without a known source
* endpoint. OpenAI is first so deployments that expose it keep prior behavior;
* any other configured endpoint is still selected when these are unavailable.
*/
const DEFAULT_ENDPOINT_PREFERENCE = [
EModelEndpoint.openAI,
EModelEndpoint.anthropic,
EModelEndpoint.google,
EModelEndpoint.azureOpenAI,
EModelEndpoint.bedrock,
];
/**
* Endpoints excluded as fork targets because they are stateful: each
* conversation needs an assistant_id and thread_id that a cloned conversation
* never creates, so the assistants chat controller rejects the first follow-up
* ("Missing thread_id for existing conversation"). A fork must land on a
* stateless chat endpoint. These can still surface in the runtime models config
* (e.g. a deployment exposing only assistant models), so filter them out.
*/
const EXCLUDED_FORK_ENDPOINTS = new Set([
EModelEndpoint.assistants,
EModelEndpoint.azureAssistants,
]);
/**
* Resolves an endpoint and model the requesting user can actually use, for
* conversations cloned without a known source endpoint (shared forks, whose
* original endpoint is stripped from the sanitized payload). Picks the first
* preferred endpoint exposing models, then any other configured endpoint
* (excluding stateful assistant endpoints, which a fork cannot resume), so a
* deployment that doesn't expose OpenAI doesn't produce a conversation whose
* first message is rejected by model validation. Falls back to OpenAI defaults
* only when the runtime models config is empty or unavailable.
*
* @param {object} args
* @param {string} args.requestUserId - The id of the requesting user.
* @param {string} [args.userRole] - The role of the requesting user.
* @returns {Promise<{ endpoint: string, model: string }>} A usable endpoint and model.
*/
async function resolveImportDefaultEndpoint({ requestUserId, userRole }) {
try {
const modelsConfig = await getModelsConfig({
user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
});
if (modelsConfig) {
const orderedEndpoints = [
...DEFAULT_ENDPOINT_PREFERENCE,
...Object.keys(modelsConfig).filter(
(endpoint) => !DEFAULT_ENDPOINT_PREFERENCE.includes(endpoint),
),
];
for (const endpoint of orderedEndpoints) {
if (EXCLUDED_FORK_ENDPOINTS.has(endpoint)) {
continue;
}
const model = pickFirstConfiguredModel(endpoint, modelsConfig);
if (model) {
return { endpoint, model };
}
}
}
} catch (error) {
logger.warn(
`[import] Failed to resolve a default endpoint from modelsConfig: ${error.message}`,
);
}
return {
endpoint: EModelEndpoint.openAI,
model: FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI] ?? openAISettings.model.default,
};
}
module.exports = {
FALLBACK_MODEL_BY_ENDPOINT,
pickFirstConfiguredModel,
resolveImportDefaultModel,
resolveImportDefaultEndpoint,
};
+205
View File
@@ -0,0 +1,205 @@
const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider');
const mockGetModelsConfig = jest.fn();
jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: (...args) => mockGetModelsConfig(...args),
}));
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
getTenantId: () => 'test-tenant',
logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() },
};
});
const {
pickFirstConfiguredModel,
resolveImportDefaultModel,
resolveImportDefaultEndpoint,
FALLBACK_MODEL_BY_ENDPOINT,
} = require('./defaults');
afterEach(() => {
jest.clearAllMocks();
});
describe('pickFirstConfiguredModel', () => {
it('returns the first non-empty string for the endpoint', () => {
const modelsConfig = {
[EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'],
};
expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, modelsConfig)).toBe(
'claude-opus-4-7',
);
});
it('skips empty strings', () => {
const modelsConfig = {
[EModelEndpoint.openAI]: ['', 'gpt-4o'],
};
expect(pickFirstConfiguredModel(EModelEndpoint.openAI, modelsConfig)).toBe('gpt-4o');
});
it('returns undefined when modelsConfig is missing', () => {
expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, undefined)).toBeUndefined();
});
it('returns undefined when the endpoint has no models', () => {
expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, {})).toBeUndefined();
expect(
pickFirstConfiguredModel(EModelEndpoint.anthropic, { [EModelEndpoint.anthropic]: [] }),
).toBeUndefined();
});
it('returns undefined when the endpoint value is not an array', () => {
expect(
pickFirstConfiguredModel(EModelEndpoint.anthropic, {
[EModelEndpoint.anthropic]: 'claude-opus-4-7',
}),
).toBeUndefined();
});
});
describe('resolveImportDefaultModel', () => {
it('returns the first model from modelsConfig when present', async () => {
mockGetModelsConfig.mockResolvedValueOnce({
[EModelEndpoint.anthropic]: ['claude-opus-4-7'],
});
const result = await resolveImportDefaultModel({
endpoint: EModelEndpoint.anthropic,
requestUserId: 'user-1',
userRole: 'USER',
});
expect(result).toBe('claude-opus-4-7');
expect(mockGetModelsConfig).toHaveBeenCalledWith({
user: { id: 'user-1', role: 'USER', tenantId: 'test-tenant' },
});
});
it('falls back to the per-endpoint default when modelsConfig has no models for the endpoint', async () => {
mockGetModelsConfig.mockResolvedValueOnce({});
const result = await resolveImportDefaultModel({
endpoint: EModelEndpoint.anthropic,
requestUserId: 'user-1',
});
expect(result).toBe(anthropicSettings.model.default);
});
it('falls back to the openAI default for unknown endpoints with no modelsConfig entry', async () => {
mockGetModelsConfig.mockResolvedValueOnce({});
const result = await resolveImportDefaultModel({
endpoint: 'some-custom-endpoint',
requestUserId: 'user-1',
});
expect(result).toBe(openAISettings.model.default);
});
it('falls back to the per-endpoint default when getModelsConfig rejects', async () => {
mockGetModelsConfig.mockRejectedValueOnce(new Error('boom'));
const result = await resolveImportDefaultModel({
endpoint: EModelEndpoint.anthropic,
requestUserId: 'user-1',
});
expect(result).toBe(anthropicSettings.model.default);
});
it('exposes hardcoded fallbacks for openAI and anthropic', () => {
expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI]).toBe(openAISettings.model.default);
expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.anthropic]).toBe(
anthropicSettings.model.default,
);
});
});
describe('resolveImportDefaultEndpoint', () => {
it('prefers OpenAI when it exposes models', async () => {
mockGetModelsConfig.mockResolvedValueOnce({
[EModelEndpoint.openAI]: ['gpt-4o'],
[EModelEndpoint.anthropic]: ['claude-opus-4-7'],
});
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({ endpoint: EModelEndpoint.openAI, model: 'gpt-4o' });
});
it('falls back to another configured endpoint when OpenAI is unavailable', async () => {
mockGetModelsConfig.mockResolvedValueOnce({
[EModelEndpoint.openAI]: [],
[EModelEndpoint.anthropic]: ['claude-opus-4-7'],
});
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({ endpoint: EModelEndpoint.anthropic, model: 'claude-opus-4-7' });
});
it('selects a custom endpoint when no preferred endpoint has models', async () => {
mockGetModelsConfig.mockResolvedValueOnce({
'my-custom': ['custom-model-1'],
});
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' });
});
it('skips stateful assistant endpoints and selects a stateless one', async () => {
mockGetModelsConfig.mockResolvedValueOnce({
[EModelEndpoint.assistants]: ['gpt-4o'],
[EModelEndpoint.azureAssistants]: ['gpt-4o'],
'my-custom': ['custom-model-1'],
});
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({ endpoint: 'my-custom', model: 'custom-model-1' });
});
it('falls back to OpenAI defaults when only assistant endpoints expose models', async () => {
mockGetModelsConfig.mockResolvedValueOnce({
[EModelEndpoint.assistants]: ['gpt-4o'],
[EModelEndpoint.azureAssistants]: ['gpt-4o'],
});
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({
endpoint: EModelEndpoint.openAI,
model: openAISettings.model.default,
});
});
it('falls back to OpenAI defaults when the models config is empty', async () => {
mockGetModelsConfig.mockResolvedValueOnce({});
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({
endpoint: EModelEndpoint.openAI,
model: openAISettings.model.default,
});
});
it('falls back to OpenAI defaults when getModelsConfig rejects', async () => {
mockGetModelsConfig.mockRejectedValueOnce(new Error('boom'));
const result = await resolveImportDefaultEndpoint({ requestUserId: 'user-1' });
expect(result).toEqual({
endpoint: EModelEndpoint.openAI,
model: openAISettings.model.default,
});
});
});
+553
View File
@@ -0,0 +1,553 @@
const { v4: uuidv4 } = require('uuid');
const { logger, tenantStorage } = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider');
const { getConvo, getMessages, getSharedMessages } = require('~/models');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const { getAppConfig } = require('~/server/services/Config');
const { resolveImportDefaultEndpoint } = require('./defaults');
const BaseClient = require('~/app/clients/BaseClient');
/**
* Helper function to clone messages with proper parent-child relationships and timestamps
* @param {TMessage[]} messagesToClone - Original messages to clone
* @param {ImportBatchBuilder} importBatchBuilder - Instance of ImportBatchBuilder
* @returns {Map<string, string>} Map of original messageIds to new messageIds
*/
function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
const idMapping = new Map();
// First pass: create ID mapping and sort messages by parentMessageId
const sortedMessages = [...messagesToClone].sort((a, b) => {
if (a.parentMessageId === Constants.NO_PARENT) {
return -1;
}
if (b.parentMessageId === Constants.NO_PARENT) {
return 1;
}
return 0;
});
// Helper function to ensure date object
const ensureDate = (dateValue) => {
if (!dateValue) {
return new Date();
}
return dateValue instanceof Date ? dateValue : new Date(dateValue);
};
// Second pass: clone messages while maintaining proper timestamps
for (const message of sortedMessages) {
const newMessageId = uuidv4();
idMapping.set(message.messageId, newMessageId);
const parentId =
message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT
? idMapping.get(message.parentMessageId)
: Constants.NO_PARENT;
// If this message has a parent, ensure its timestamp is after the parent's
let createdAt = ensureDate(message.createdAt);
if (parentId !== Constants.NO_PARENT) {
const parentMessage = importBatchBuilder.messages.find((msg) => msg.messageId === parentId);
if (parentMessage) {
const parentDate = ensureDate(parentMessage.createdAt);
if (createdAt <= parentDate) {
createdAt = new Date(parentDate.getTime() + 1);
}
}
}
const clonedMessage = {
...message,
messageId: newMessageId,
parentMessageId: parentId,
createdAt,
};
importBatchBuilder.saveMessage(clonedMessage);
}
return idMapping;
}
/**
*
* @param {object} params - The parameters for the importer.
* @param {string} params.originalConvoId - The ID of the conversation to fork.
* @param {string} params.targetMessageId - The ID of the message to fork from.
* @param {string} params.requestUserId - The ID of the user making the request.
* @param {string} [params.newTitle] - Optional new title for the forked conversation uses old title if not provided
* @param {string} [params.option=''] - Optional flag for fork option
* @param {boolean} [params.records=false] - Optional flag for returning actual database records or resulting conversation and messages.
* @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level.
* @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true.
* @param {(userId: string) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @returns {Promise<TForkConvoResponse>} The response after forking the conversation.
*/
async function forkConversation({
originalConvoId,
targetMessageId: targetId,
requestUserId,
newTitle,
option = ForkOptions.TARGET_LEVEL,
records = false,
splitAtTarget = false,
latestMessageId,
builderFactory = createImportBatchBuilder,
}) {
try {
const originalConvo = await getConvo(requestUserId, originalConvoId);
let originalMessages = await getMessages({
user: requestUserId,
conversationId: originalConvoId,
});
let targetMessageId = targetId;
if (splitAtTarget && !latestMessageId) {
throw new Error('Latest `messageId` is required for forking from target message.');
} else if (splitAtTarget) {
originalMessages = splitAtTargetLevel(originalMessages, targetId);
targetMessageId = latestMessageId;
}
const importBatchBuilder = builderFactory(requestUserId);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
let messagesToClone = [];
if (option === ForkOptions.DIRECT_PATH) {
// Direct path only
messagesToClone = BaseClient.getMessagesForConversation({
messages: originalMessages,
parentMessageId: targetMessageId,
});
} else if (option === ForkOptions.INCLUDE_BRANCHES) {
// Direct path and siblings
messagesToClone = getAllMessagesUpToParent(originalMessages, targetMessageId);
} else if (option === ForkOptions.TARGET_LEVEL || !option) {
// Direct path, siblings, and all descendants
messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId);
}
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
const result = importBatchBuilder.finishConversation(
newTitle || originalConvo.title,
new Date(),
originalConvo,
);
await importBatchBuilder.saveBatch();
logger.debug(
`user: ${requestUserId} | New conversation "${
newTitle || originalConvo.title
}" forked from conversation ID ${originalConvoId}`,
);
if (!records) {
return result;
}
const conversation = await getConvo(requestUserId, result.conversation.conversationId);
const messages = await getMessages({
user: requestUserId,
conversationId: conversation.conversationId,
});
return {
conversation,
messages,
};
} catch (error) {
logger.error(
`user: ${requestUserId} | Error forking conversation from original ID ${originalConvoId}`,
error,
);
throw error;
}
}
/**
* Retrieves all messages up to the root from the target message.
* @param {TMessage[]} messages - The list of messages to search.
* @param {string} targetMessageId - The ID of the target message.
* @returns {TMessage[]} The list of messages up to the root from the target message.
*/
function getAllMessagesUpToParent(messages, targetMessageId) {
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
if (!targetMessage) {
return [];
}
const pathToRoot = new Set();
const visited = new Set();
let current = targetMessage;
while (current) {
if (visited.has(current.messageId)) {
break;
}
visited.add(current.messageId);
pathToRoot.add(current.messageId);
const currentParentId = current.parentMessageId ?? Constants.NO_PARENT;
if (currentParentId === Constants.NO_PARENT) {
break;
}
current = messages.find((msg) => msg.messageId === currentParentId);
}
// Include all messages that are in the path or whose parent is in the path
// Exclude children of the target message
return messages.filter(
(msg) =>
(pathToRoot.has(msg.messageId) && msg.messageId !== targetMessageId) ||
(pathToRoot.has(msg.parentMessageId) && msg.parentMessageId !== targetMessageId) ||
msg.messageId === targetMessageId,
);
}
/**
* Retrieves all messages up to the root from the target message and its neighbors.
* @param {TMessage[]} messages - The list of messages to search.
* @param {string} targetMessageId - The ID of the target message.
* @returns {TMessage[]} The list of inclusive messages up to the root from the target message.
*/
function getMessagesUpToTargetLevel(messages, targetMessageId) {
if (messages.length === 1 && messages[0] && messages[0].messageId === targetMessageId) {
return messages;
}
// Create a map of parentMessageId to children messages
const parentToChildrenMap = new Map();
for (const message of messages) {
if (!parentToChildrenMap.has(message.parentMessageId)) {
parentToChildrenMap.set(message.parentMessageId, []);
}
parentToChildrenMap.get(message.parentMessageId).push(message);
}
// Retrieve the target message
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
if (!targetMessage) {
logger.error('Target message not found.');
return [];
}
const visited = new Set();
const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage];
const results = new Set(currentLevel);
// Check if the target message is at the root level
if (
currentLevel.some((msg) => msg.messageId === targetMessageId) &&
targetMessage.parentMessageId === Constants.NO_PARENT
) {
return Array.from(results);
}
// Iterate level by level until the target is found
let targetFound = false;
while (!targetFound && currentLevel.length > 0) {
const nextLevel = [];
for (const node of currentLevel) {
if (visited.has(node.messageId)) {
logger.warn('Cycle detected in message tree');
continue;
}
visited.add(node.messageId);
const children = parentToChildrenMap.get(node.messageId) || [];
for (const child of children) {
if (visited.has(child.messageId)) {
logger.warn('Cycle detected in message tree');
continue;
}
nextLevel.push(child);
results.add(child);
if (child.messageId === targetMessageId) {
targetFound = true;
}
}
}
currentLevel = nextLevel;
}
return Array.from(results);
}
/**
* Splits the conversation at the targeted message level, including the target, its siblings, and all descendant messages.
* All target level messages have their parentMessageId set to the root.
* @param {TMessage[]} messages - The list of messages to analyze.
* @param {string} targetMessageId - The ID of the message to start the split from.
* @returns {TMessage[]} The list of messages at and below the target level.
*/
function splitAtTargetLevel(messages, targetMessageId) {
// Create a map of parentMessageId to children messages
const parentToChildrenMap = new Map();
for (const message of messages) {
if (!parentToChildrenMap.has(message.parentMessageId)) {
parentToChildrenMap.set(message.parentMessageId, []);
}
parentToChildrenMap.get(message.parentMessageId).push(message);
}
// Retrieve the target message
const targetMessage = messages.find((msg) => msg.messageId === targetMessageId);
if (!targetMessage) {
logger.error('Target message not found.');
return [];
}
// Initialize the search with root messages
const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || [];
let currentLevel = [...rootMessages];
let currentLevelIndex = 0;
const levelMap = {};
// Map messages to their levels
rootMessages.forEach((msg) => {
levelMap[msg.messageId] = 0;
});
// Search for the target level
while (currentLevel.length > 0) {
const nextLevel = [];
for (const node of currentLevel) {
const children = parentToChildrenMap.get(node.messageId) || [];
for (const child of children) {
nextLevel.push(child);
levelMap[child.messageId] = currentLevelIndex + 1;
}
}
currentLevel = nextLevel;
currentLevelIndex++;
}
// Determine the target level
const targetLevel = levelMap[targetMessageId];
if (targetLevel === undefined) {
logger.error('Target level not found.');
return [];
}
// Filter messages at or below the target level
const filteredMessages = messages
.map((msg) => {
const messageLevel = levelMap[msg.messageId];
if (messageLevel < targetLevel) {
return null;
} else if (messageLevel === targetLevel) {
return {
...msg,
parentMessageId: Constants.NO_PARENT,
};
}
return msg;
})
.filter((msg) => msg !== null);
return filteredMessages;
}
/**
* Strips file identifiers from a shared message's `files` and `attachments`.
* A shared fork is owned by the requesting user, but the underlying file records
* still belong to the original sharer. Persisting their `file_id`s would let the
* agents file-resend path collect them on the next turn and call `getUserCodeFiles`,
* which looks them up by `file_id` with no ownership filter, rehydrating the
* sharer's files into the viewer's run. Dropping the ids keeps a fork's file
* access no broader than viewing the read-only share, while leaving render-only
* metadata (e.g. `filepath`, `toolCallId`) intact.
* @param {TMessage} message - The shared message to sanitize.
* @returns {TMessage} The message with file identifiers removed.
*/
function stripSharedFileIds(message) {
const sanitized = { ...message };
if (Array.isArray(sanitized.files)) {
sanitized.files = sanitized.files.map(({ file_id: _fileId, ...file }) => file);
}
if (Array.isArray(sanitized.attachments)) {
sanitized.attachments = sanitized.attachments.map(
({ file_id: _fileId, ...attachment }) => attachment,
);
}
return sanitized;
}
/**
* Forks a shared (sanitized) conversation into a fresh conversation owned by the requesting user.
* Only the anonymized, allowlisted message fields returned by `getSharedMessages` are cloned,
* so no private data from the original owner can leak into the new conversation.
* @param {object} params - The parameters for forking the shared conversation.
* @param {string} params.shareId - The ID of the shared link to fork from.
* @param {string} [params.shareResourceId] - The SharedLink resource ID set by `canAccessSharedLink`.
* @param {string} params.requestUserId - The ID of the user making the request.
* @param {string} [params.userRole] - The role of the requesting user, used to resolve the default model.
* @param {string} [params.userTenantId] - Tenant of the requesting user. `canAccessSharedLink` runs this handler under the share owner's tenant so the share resolves, so the copy must be persisted (and its config/retention resolved) under the requesting user's tenant or it would be invisible (404) when they open it normally.
* @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server.
* @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored.
* @param {(userId: string, interfaceConfig?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance.
* @param {(options: object) => Promise<object>} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's.
* @returns {Promise<TForkConvoResponse | null>} The new conversation and messages, or null when the share is missing or empty.
*/
async function forkSharedConversation({
shareId,
shareResourceId,
requestUserId,
userRole,
userTenantId,
targetMessageIndex,
snapshotFiles,
builderFactory = createImportBatchBuilder,
loadAppConfig = getAppConfig,
}) {
// Mirror the GET share route: when the shared-file snapshot is globally
// disabled, omit file/attachment metadata so a fork can't persist filenames
// or share file URLs into the new conversation while file serving is off.
const share = await getSharedMessages(shareId, shareResourceId, { snapshotFiles });
if (!share?.messages?.length) {
return null;
}
/**
* The shared payload includes sibling branches. Reduce to the direct path of
* the viewer's active message so the fork continues exactly the branch that
* was shown; without this the default branch selection lands on the newest
* sibling. The active tip is located by its index in the shared payload, which
* `getSharedMessages` returns in a deterministic order (stored ref-array order)
* — unlike ids (re-anonymized per request) or `createdAt` (can collide). Falls
* back to the full set when the index is absent or out of range.
*/
let sourceMessages = share.messages;
if (
Number.isInteger(targetMessageIndex) &&
targetMessageIndex >= 0 &&
targetMessageIndex < share.messages.length
) {
const targetMessage = share.messages[targetMessageIndex];
const directPath = BaseClient.getMessagesForConversation({
messages: share.messages,
parentMessageId: targetMessage.messageId,
});
if (directPath.length > 0) {
sourceMessages = directPath;
}
}
const messageIds = new Set(sourceMessages.map((message) => message.messageId));
const messagesToClone = sourceMessages.map(({ model: _model, ...message }) =>
stripSharedFileIds({
...message,
parentMessageId:
message.parentMessageId != null && messageIds.has(message.parentMessageId)
? message.parentMessageId
: Constants.NO_PARENT,
}),
);
/**
* Persist and read back under the requesting user's tenant rather than the
* share owner's. The read above runs in the share owner's tenant (set by
* `canAccessSharedLink`); writing the copy there would leave it invisible to
* the user under their normal tenant context (the new conversation would 404
* when they navigate to it). Switching to the user's tenant only affects this
* deployment when tenant isolation is enabled; otherwise it is a no-op.
*/
return tenantStorage.run({ tenantId: userTenantId, userId: requestUserId }, async () => {
// Resolve config inside the viewer's tenant so retention (e.g. all-data
// expiry) reflects the requesting user's tenant, not the share owner's.
const appConfig = await loadAppConfig({
role: userRole,
userId: requestUserId,
tenantId: userTenantId,
});
// The shared payload strips the original endpoint, so resolve one the viewer
// can actually use; hard-coding OpenAI breaks the first follow-up message on
// deployments that don't expose it.
const { endpoint, model } = await resolveImportDefaultEndpoint({ requestUserId, userRole });
const importBatchBuilder = builderFactory(requestUserId, appConfig?.interfaceConfig);
importBatchBuilder.startConversation(endpoint);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
const result = importBatchBuilder.finishConversation(share.title, new Date(), {}, model);
await importBatchBuilder.saveBatch();
logger.debug(
`user: ${requestUserId} | New conversation "${result.conversation.title}" forked from share ID ${shareId}`,
);
const conversation = await getConvo(requestUserId, result.conversation.conversationId);
const messages = await getMessages({
user: requestUserId,
conversationId: conversation.conversationId,
});
return {
conversation,
messages,
};
});
}
/**
* Duplicates a conversation and all its messages.
* @param {object} params - The parameters for duplicating the conversation.
* @param {string} params.userId - The ID of the user duplicating the conversation.
* @param {string} params.conversationId - The ID of the conversation to duplicate.
* @param {string} [params.title] - Optional title override for the duplicate.
* @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages.
*/
async function duplicateConversation({ userId, conversationId, title }) {
const originalConvo = await getConvo(userId, conversationId);
if (!originalConvo) {
throw new Error('Conversation not found');
}
const originalMessages = await getMessages({
user: userId,
conversationId,
});
const messagesToClone = getMessagesUpToTargetLevel(
originalMessages,
originalMessages[originalMessages.length - 1].messageId,
);
const importBatchBuilder = createImportBatchBuilder(userId);
importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
const duplicateTitle = title || originalConvo.title;
const result = importBatchBuilder.finishConversation(duplicateTitle, new Date(), originalConvo);
await importBatchBuilder.saveBatch();
logger.debug(
`user: ${userId} | New conversation "${duplicateTitle}" duplicated from conversation ID ${conversationId}`,
);
const conversation = await getConvo(userId, result.conversation.conversationId);
const messages = await getMessages({
user: userId,
conversationId: conversation.conversationId,
});
return {
conversation,
messages,
};
}
module.exports = {
forkConversation,
splitAtTargetLevel,
duplicateConversation,
forkSharedConversation,
getAllMessagesUpToParent,
getMessagesUpToTargetLevel,
cloneMessagesWithTimestamps,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
const { v4: uuidv4 } = require('uuid');
const {
logger,
createFallbackRetentionDate,
createTempChatExpirationDate,
} = require('@librechat/data-schemas');
const {
EModelEndpoint,
Constants,
RetentionMode,
openAISettings,
} = require('librechat-data-provider');
const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models');
const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults');
/**
* Factory function for creating an instance of ImportBatchBuilder.
* @param {string} requestUserId - The ID of the user making the request.
* @param {object} [interfaceConfig] - Runtime interface config for import retention.
* @returns {ImportBatchBuilder} - The newly created ImportBatchBuilder instance.
*/
function createImportBatchBuilder(requestUserId, interfaceConfig) {
return new ImportBatchBuilder(requestUserId, interfaceConfig);
}
/**
* Class for building a batch of conversations and messages and pushing them to DB for Conversation Import functionality
*/
class ImportBatchBuilder {
/**
* Creates an instance of ImportBatchBuilder.
* @param {string} requestUserId - The ID of the user making the import request.
* @param {object} [interfaceConfig] - Runtime interface config for import retention.
*/
constructor(requestUserId, interfaceConfig) {
this.requestUserId = requestUserId;
this.interfaceConfig = interfaceConfig;
this.conversations = [];
this.messages = [];
this.retentionFields = undefined;
}
getRetentionFields() {
if (this.retentionFields !== undefined) {
return this.retentionFields;
}
if (this.interfaceConfig?.retentionMode !== RetentionMode.ALL) {
this.retentionFields = {};
return this.retentionFields;
}
try {
this.retentionFields = {
isTemporary: false,
expiredAt: createTempChatExpirationDate(this.interfaceConfig),
};
} catch (error) {
logger.error('[ImportBatchBuilder] Error creating import expiration date:', error);
this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() };
}
return this.retentionFields;
}
/**
* Starts a new conversation in the batch.
* @param {string} [endpoint=EModelEndpoint.openAI] - The endpoint for the conversation. Defaults to EModelEndpoint.openAI.
* @returns {void}
*/
startConversation(endpoint) {
// we are simplifying by using a single model for the entire conversation
this.endpoint = endpoint || EModelEndpoint.openAI;
this.conversationId = uuidv4();
this.lastMessageId = Constants.NO_PARENT;
}
/**
* Adds a user message to the current conversation.
* @param {string} text - The text of the user message.
* @returns {object} The saved message object.
*/
addUserMessage(text) {
const message = this.saveMessage({ text, sender: 'user', isCreatedByUser: true });
return message;
}
/**
* Adds a GPT message to the current conversation.
* @param {string} text - The text of the GPT message.
* @param {string} [model='defaultModel'] - The model used for generating the GPT message. Defaults to 'defaultModel'.
* @param {string} [sender='GPT-3.5'] - The sender of the GPT message. Defaults to 'GPT-3.5'.
* @returns {object} The saved message object.
*/
addGptMessage(text, model, sender = 'GPT-3.5') {
const message = this.saveMessage({
text,
sender,
isCreatedByUser: false,
model: model || openAISettings.model.default,
});
return message;
}
/**
* Finishes the current conversation and adds it to the batch.
* @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'.
* @param {Date} [createdAt] - The creation date of the conversation.
* @param {TConversation} [originalConvo] - The original conversation.
* @param {string} [defaultModel] - Resolved default model for this endpoint
* (typically derived from the runtime models config). Used only when
* originalConvo.model is unset.
* @returns {{ conversation: TConversation, messages: TMessage[] }} The resulting conversation and messages.
*/
finishConversation(title, createdAt, originalConvo = {}, defaultModel) {
const fallbackModel =
defaultModel ?? FALLBACK_MODEL_BY_ENDPOINT[this.endpoint] ?? openAISettings.model.default;
const convo = {
...originalConvo,
user: this.requestUserId,
conversationId: this.conversationId,
title: title || 'Imported Chat',
createdAt: createdAt,
updatedAt: createdAt,
overrideTimestamp: true,
endpoint: this.endpoint,
model: originalConvo.model ?? fallbackModel,
...this.getRetentionFields(),
};
convo._id && delete convo._id;
this.conversations.push(convo);
return { conversation: convo, messages: this.messages };
}
/**
* Saves the batch of conversations and messages to the DB.
* Also increments tag counts for any existing tags.
* @returns {Promise<void>} A promise that resolves when the batch is saved.
* @throws {Error} If there is an error saving the batch.
*/
async saveBatch() {
try {
const promises = [];
promises.push(bulkSaveConvos(this.conversations));
promises.push(bulkSaveMessages(this.messages, true));
promises.push(
bulkIncrementTagCounts(
this.requestUserId,
this.conversations.flatMap((convo) => convo.tags),
),
);
await Promise.all(promises);
logger.debug(
`user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`,
);
} catch (error) {
logger.error('Error saving batch', error);
throw error;
}
}
/**
* Saves a message to the current conversation.
* @param {object} messageDetails - The details of the message.
* @param {string} messageDetails.text - The text of the message.
* @param {string} messageDetails.sender - The sender of the message.
* @param {string} [messageDetails.messageId] - The ID of the current message.
* @param {boolean} messageDetails.isCreatedByUser - Indicates whether the message is created by the user.
* @param {string} [messageDetails.model] - The model used for generating the message.
* @param {string} [messageDetails.endpoint] - The endpoint used for generating the message.
* @param {string} [messageDetails.parentMessageId=this.lastMessageId] - The ID of the parent message.
* @param {Partial<TMessage>} messageDetails.rest - Additional properties that may be included in the message.
* @returns {object} The saved message object.
*/
saveMessage({
text,
sender,
isCreatedByUser,
model,
messageId,
parentMessageId = this.lastMessageId,
endpoint,
...rest
}) {
const newMessageId = messageId ?? uuidv4();
const message = {
...rest,
parentMessageId,
messageId: newMessageId,
conversationId: this.conversationId,
isCreatedByUser: isCreatedByUser,
model: model || this.model,
user: this.requestUserId,
endpoint: endpoint ?? this.endpoint,
unfinished: false,
isEdited: false,
error: false,
sender,
text,
...this.getRetentionFields(),
};
message._id && delete message._id;
this.lastMessageId = newMessageId;
this.messages.push(message);
return message;
}
}
module.exports = { ImportBatchBuilder, createImportBatchBuilder };
@@ -0,0 +1,47 @@
const fs = require('fs').promises;
const { resolveImportMaxFileSize } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getImporter } = require('./importers');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const maxFileSize = resolveImportMaxFileSize();
/**
* Job definition for importing a conversation.
* @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object }} job
*/
const importConversations = async (job) => {
const { filepath, requestUserId, userRole, interfaceConfig } = job;
try {
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
const fileInfo = await fs.stat(filepath);
if (fileInfo.size > maxFileSize) {
throw new Error(
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${maxFileSize} bytes.`,
);
}
const fileData = await fs.readFile(filepath, 'utf8');
const jsonData = JSON.parse(fileData);
const importer = getImporter(jsonData);
await importer(
jsonData,
requestUserId,
(userId) => createImportBatchBuilder(userId, interfaceConfig),
userRole,
);
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
} catch (error) {
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
throw error; // throw error all the way up so request does not return success
} finally {
try {
await fs.unlink(filepath);
} catch (error) {
logger.error(`user: ${requestUserId} | Failed to delete file: ${filepath}`, error);
}
}
};
module.exports = importConversations;
@@ -0,0 +1,533 @@
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { ImportBatchBuilder } = require('./importBatchBuilder');
const { getImporter } = require('./importers');
// Mock the database methods
jest.mock('~/models', () => ({
bulkSaveConvos: jest.fn(),
bulkSaveMessages: jest.fn(),
bulkIncrementTagCounts: jest.fn(),
}));
const mockGetEndpointsConfig = jest.fn().mockResolvedValue(null);
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
}));
describe('Import Timestamp Ordering', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetEndpointsConfig.mockResolvedValue(null);
});
describe('LibreChat Import - Timestamp Issues', () => {
test('should maintain proper timestamp order between parent and child messages', async () => {
// Create a LibreChat export with out-of-order timestamps
const jsonData = {
conversationId: 'test-convo-123',
title: 'Test Conversation',
messages: [
{
messageId: 'parent-1',
parentMessageId: Constants.NO_PARENT,
text: 'Parent Message',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
},
{
messageId: 'child-1',
parentMessageId: 'parent-1',
text: 'Child Message',
sender: 'assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
},
{
messageId: 'grandchild-1',
parentMessageId: 'child-1',
text: 'Grandchild Message',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:00:30Z', // Even earlier
},
],
};
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
// Check the actual messages stored in the builder
const savedMessages = importBatchBuilder.messages;
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
const child = savedMessages.find((msg) => msg.text === 'Child Message');
const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
// Verify all messages were found
expect(parent).toBeDefined();
expect(child).toBeDefined();
expect(grandchild).toBeDefined();
// FIXED behavior: timestamps ARE corrected
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
new Date(child.createdAt).getTime(),
);
});
test('should handle complex multi-branch scenario with out-of-order timestamps', async () => {
const jsonData = {
conversationId: 'complex-test-123',
title: 'Complex Test',
messages: [
// Branch 1: Root -> A -> B with reversed timestamps
{
messageId: 'root-1',
parentMessageId: Constants.NO_PARENT,
text: 'Root 1',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:03:00Z',
},
{
messageId: 'a-1',
parentMessageId: 'root-1',
text: 'A1',
sender: 'assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:02:00Z', // Before parent
},
{
messageId: 'b-1',
parentMessageId: 'a-1',
text: 'B1',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:01:00Z', // Before grandparent
},
// Branch 2: Root -> C -> D with mixed timestamps
{
messageId: 'root-2',
parentMessageId: Constants.NO_PARENT,
text: 'Root 2',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:00:30Z', // Earlier than branch 1
},
{
messageId: 'c-2',
parentMessageId: 'root-2',
text: 'C2',
sender: 'assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:04:00Z', // Much later
},
{
messageId: 'd-2',
parentMessageId: 'c-2',
text: 'D2',
sender: 'user',
isCreatedByUser: true,
createdAt: '2023-01-01T00:02:30Z', // Between root and parent
},
],
};
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
// Verify that timestamps are preserved as-is (not corrected)
const root1 = savedMessages.find((msg) => msg.text === 'Root 1');
const a1 = savedMessages.find((msg) => msg.text === 'A1');
const b1 = savedMessages.find((msg) => msg.text === 'B1');
const root2 = savedMessages.find((msg) => msg.text === 'Root 2');
const c2 = savedMessages.find((msg) => msg.text === 'C2');
const d2 = savedMessages.find((msg) => msg.text === 'D2');
// Branch 1: timestamps should now be in correct order
expect(new Date(a1.createdAt).getTime()).toBeGreaterThan(new Date(root1.createdAt).getTime());
expect(new Date(b1.createdAt).getTime()).toBeGreaterThan(new Date(a1.createdAt).getTime());
// Branch 2: all timestamps should be properly ordered
expect(new Date(c2.createdAt).getTime()).toBeGreaterThan(new Date(root2.createdAt).getTime());
expect(new Date(d2.createdAt).getTime()).toBeGreaterThan(new Date(c2.createdAt).getTime());
});
test('recursive format should NOW have timestamp protection', async () => {
// Create a recursive LibreChat export with out-of-order timestamps
const jsonData = {
conversationId: 'recursive-test-123',
title: 'Recursive Test',
recursive: true,
messages: [
{
messageId: 'parent-1',
parentMessageId: Constants.NO_PARENT,
text: 'Parent Message',
sender: 'User',
isCreatedByUser: true,
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
children: [
{
messageId: 'child-1',
parentMessageId: 'parent-1',
text: 'Child Message',
sender: 'Assistant',
isCreatedByUser: false,
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
children: [
{
messageId: 'grandchild-1',
parentMessageId: 'child-1',
text: 'Grandchild Message',
sender: 'User',
isCreatedByUser: true,
createdAt: '2023-01-01T00:00:30Z', // Even earlier
children: [],
},
],
},
],
},
],
};
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
// Messages should be saved
expect(savedMessages).toHaveLength(3);
// In recursive format, timestamps are NOT included in the saved messages
// The saveMessage method doesn't receive createdAt for recursive imports
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
const child = savedMessages.find((msg) => msg.text === 'Child Message');
const grandchild = savedMessages.find((msg) => msg.text === 'Grandchild Message');
expect(parent).toBeDefined();
expect(child).toBeDefined();
expect(grandchild).toBeDefined();
// Recursive imports NOW preserve and correct timestamps
expect(parent.createdAt).toBeDefined();
expect(child.createdAt).toBeDefined();
expect(grandchild.createdAt).toBeDefined();
// Timestamps should be corrected to maintain proper order
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
expect(new Date(grandchild.createdAt).getTime()).toBeGreaterThan(
new Date(child.createdAt).getTime(),
);
});
});
describe('ChatGPT Import - Timestamp Issues', () => {
test('should correct timestamp inversions (child before parent)', async () => {
// Simulate ChatGPT export with timestamp inversion (like tool call results)
const jsonData = [
{
title: 'Timestamp Inversion Test',
create_time: 1000,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['parent-msg'],
},
'parent-msg': {
id: 'parent-msg',
message: {
id: 'parent-msg',
author: { role: 'user' },
create_time: 1000.1, // Parent: 1000.1
content: { content_type: 'text', parts: ['Parent message'] },
metadata: {},
},
parent: 'root-node',
children: ['child-msg'],
},
'child-msg': {
id: 'child-msg',
message: {
id: 'child-msg',
author: { role: 'assistant' },
create_time: 1000.095, // Child: 1000.095 (5ms BEFORE parent)
content: { content_type: 'text', parts: ['Child message'] },
metadata: {},
},
parent: 'parent-msg',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
const parent = savedMessages.find((msg) => msg.text === 'Parent message');
const child = savedMessages.find((msg) => msg.text === 'Child message');
expect(parent).toBeDefined();
expect(child).toBeDefined();
// Child timestamp should be adjusted to be after parent
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
});
test('should use conv.create_time for null message timestamps', async () => {
const convCreateTime = 1500000000; // Conversation create time
const jsonData = [
{
title: 'Null Timestamp Test',
create_time: convCreateTime,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['msg-with-null-time'],
},
'msg-with-null-time': {
id: 'msg-with-null-time',
message: {
id: 'msg-with-null-time',
author: { role: 'user' },
create_time: null, // Null timestamp
content: { content_type: 'text', parts: ['Message with null time'] },
metadata: {},
},
parent: 'root-node',
children: ['msg-with-valid-time'],
},
'msg-with-valid-time': {
id: 'msg-with-valid-time',
message: {
id: 'msg-with-valid-time',
author: { role: 'assistant' },
create_time: convCreateTime + 10, // Valid timestamp
content: { content_type: 'text', parts: ['Message with valid time'] },
metadata: {},
},
parent: 'msg-with-null-time',
children: [],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
const nullTimeMsg = savedMessages.find((msg) => msg.text === 'Message with null time');
const validTimeMsg = savedMessages.find((msg) => msg.text === 'Message with valid time');
expect(nullTimeMsg).toBeDefined();
expect(validTimeMsg).toBeDefined();
// Null timestamp should fall back to conv.create_time
expect(nullTimeMsg.createdAt).toEqual(new Date(convCreateTime * 1000));
// Child should still be after parent (timestamp adjustment)
expect(new Date(validTimeMsg.createdAt).getTime()).toBeGreaterThan(
new Date(nullTimeMsg.createdAt).getTime(),
);
});
test('should terminate on cyclic parent relationships and break cycles before saving', async () => {
const warnSpy = jest.spyOn(logger, 'warn');
const jsonData = [
{
title: 'Cycle Test',
create_time: 1700000000,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['message-a'],
},
'message-a': {
id: 'message-a',
message: {
id: 'message-a',
author: { role: 'user' },
create_time: 1700000000,
content: { content_type: 'text', parts: ['Message A'] },
metadata: {},
},
parent: 'message-b',
children: ['message-b'],
},
'message-b': {
id: 'message-b',
message: {
id: 'message-b',
author: { role: 'assistant' },
create_time: 1700000000,
content: { content_type: 'text', parts: ['Message B'] },
metadata: {},
},
parent: 'message-a',
children: ['message-a'],
},
},
},
];
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
const importer = getImporter(jsonData);
await importer(jsonData, requestUserId, () => importBatchBuilder);
const { messages } = importBatchBuilder;
expect(messages).toHaveLength(2);
const msgA = messages.find((m) => m.text === 'Message A');
const msgB = messages.find((m) => m.text === 'Message B');
expect(msgA).toBeDefined();
expect(msgB).toBeDefined();
const roots = messages.filter((m) => m.parentMessageId === Constants.NO_PARENT);
expect(roots).toHaveLength(1);
const [root] = roots;
const nonRoot = messages.find((m) => m.parentMessageId !== Constants.NO_PARENT);
expect(nonRoot.parentMessageId).toBe(root.messageId);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('cyclic parent relationships'));
warnSpy.mockRestore();
});
test('should not hang when findValidParent encounters a skippable-message cycle', async () => {
const jsonData = [
{
title: 'Skippable Cycle Test',
create_time: 1700000000,
mapping: {
'root-node': {
id: 'root-node',
message: null,
parent: null,
children: ['real-msg'],
},
'sys-a': {
id: 'sys-a',
message: {
id: 'sys-a',
author: { role: 'system' },
create_time: 1700000000,
content: { content_type: 'text', parts: ['system a'] },
metadata: {},
},
parent: 'sys-b',
children: ['real-msg'],
},
'sys-b': {
id: 'sys-b',
message: {
id: 'sys-b',
author: { role: 'system' },
create_time: 1700000000,
content: { content_type: 'text', parts: ['system b'] },
metadata: {},
},
parent: 'sys-a',
children: [],
},
'real-msg': {
id: 'real-msg',
message: {
id: 'real-msg',
author: { role: 'user' },
create_time: 1700000001,
content: { content_type: 'text', parts: ['Hello'] },
metadata: {},
},
parent: 'sys-a',
children: [],
},
},
},
];
const importBatchBuilder = new ImportBatchBuilder('user-123');
const importer = getImporter(jsonData);
await importer(jsonData, 'user-123', () => importBatchBuilder);
const realMsg = importBatchBuilder.messages.find((m) => m.text === 'Hello');
expect(realMsg).toBeDefined();
expect(realMsg.parentMessageId).toBe(Constants.NO_PARENT);
});
});
describe('Comparison with Fork Functionality', () => {
test('fork functionality correctly handles timestamp issues (for comparison)', async () => {
const { cloneMessagesWithTimestamps } = require('./fork');
const messagesToClone = [
{
messageId: 'parent',
parentMessageId: Constants.NO_PARENT,
text: 'Parent Message',
createdAt: '2023-01-01T00:02:00Z', // Parent created AFTER child
},
{
messageId: 'child',
parentMessageId: 'parent',
text: 'Child Message',
createdAt: '2023-01-01T00:01:00Z', // Child created BEFORE parent
},
];
const importBatchBuilder = new ImportBatchBuilder('user-123');
jest.spyOn(importBatchBuilder, 'saveMessage');
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
const savedMessages = importBatchBuilder.messages;
const parent = savedMessages.find((msg) => msg.text === 'Parent Message');
const child = savedMessages.find((msg) => msg.text === 'Child Message');
// Fork functionality DOES correct the timestamps
expect(new Date(child.createdAt).getTime()).toBeGreaterThan(
new Date(parent.createdAt).getTime(),
);
});
});
});
+699
View File
@@ -0,0 +1,699 @@
const { v4: uuidv4 } = require('uuid');
const { logger, getTenantId } = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider');
const { getEndpointsConfig } = require('~/server/services/Config');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const { resolveImportDefaultModel } = require('./defaults');
const { cloneMessagesWithTimestamps } = require('./fork');
/**
* Returns the appropriate importer function based on the provided JSON data.
*
* @param {Object} jsonData - The JSON data to import.
* @returns {Function} - The importer function.
* @throws {Error} - If the import type is not supported.
*/
function getImporter(jsonData) {
// For array-based formats (ChatGPT or Claude)
if (Array.isArray(jsonData)) {
// Claude format has chat_messages array in each conversation
if (jsonData.length > 0 && jsonData[0]?.chat_messages) {
logger.info('Importing Claude conversation');
return importClaudeConvo;
}
// ChatGPT format has mapping object in each conversation
if (jsonData.length === 0 || jsonData[0]?.mapping) {
logger.info('Importing ChatGPT conversation');
return importChatGptConvo;
}
throw new Error('Unsupported import type');
}
// For ChatbotUI
if (jsonData.version && Array.isArray(jsonData.history)) {
logger.info('Importing ChatbotUI conversation');
return importChatBotUiConvo;
}
// For LibreChat
if (jsonData.conversationId && (jsonData.messagesTree || jsonData.messages)) {
logger.info('Importing LibreChat conversation');
return importLibreChatConvo;
}
throw new Error('Unsupported import type');
}
/**
* Imports a chatbot-ui V1 conversation from a JSON file and saves it to the database.
*
* @param {Object} jsonData - The JSON data containing the chatbot conversation.
* @param {string} requestUserId - The ID of the user making the import request.
* @param {Function} [builderFactory=createImportBatchBuilder] - The factory function to create an import batch builder.
* @returns {Promise<void>} - A promise that resolves when the import is complete.
* @throws {Error} - If there is an error creating the conversation from the JSON file.
*/
async function importChatBotUiConvo(
jsonData,
requestUserId,
builderFactory = createImportBatchBuilder,
userRole,
) {
// this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code
try {
/** @type {ImportBatchBuilder} */
const importBatchBuilder = builderFactory(requestUserId);
const defaultModel = await resolveImportDefaultModel({
endpoint: EModelEndpoint.openAI,
requestUserId,
userRole,
});
for (const historyItem of jsonData.history) {
importBatchBuilder.startConversation(EModelEndpoint.openAI);
for (const message of historyItem.messages) {
if (message.role === 'assistant') {
importBatchBuilder.addGptMessage(message.content, historyItem.model.id);
} else if (message.role === 'user') {
importBatchBuilder.addUserMessage(message.content);
}
}
importBatchBuilder.finishConversation(historyItem.name, new Date(), {}, defaultModel);
}
await importBatchBuilder.saveBatch();
logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from ChatbotUI file`, error);
throw error;
}
}
/**
* Extracts text and thinking content from a Claude message.
* @param {Object} msg - Claude message object with content array and optional text field.
* @returns {{textContent: string, thinkingContent: string}} Extracted text and thinking content.
*/
function extractClaudeContent(msg) {
let textContent = '';
let thinkingContent = '';
for (const part of msg.content || []) {
if (part.type === 'text' && part.text) {
textContent += part.text;
} else if (part.type === 'thinking' && part.thinking) {
thinkingContent += part.thinking;
}
}
// Use the text field as fallback if content array is empty
if (!textContent && msg.text) {
textContent = msg.text;
}
return { textContent, thinkingContent };
}
/**
* Imports Claude conversations from provided JSON data.
* Claude export format: array of conversations with chat_messages array.
*
* @param {Array} jsonData - Array of Claude conversation objects to be imported.
* @param {string} requestUserId - The ID of the user who initiated the import process.
* @param {Function} builderFactory - Factory function to create a new import batch builder instance.
* @returns {Promise<void>} Promise that resolves when all conversations have been imported.
*/
async function importClaudeConvo(
jsonData,
requestUserId,
builderFactory = createImportBatchBuilder,
userRole,
) {
try {
const importBatchBuilder = builderFactory(requestUserId);
const defaultModel = await resolveImportDefaultModel({
endpoint: EModelEndpoint.anthropic,
requestUserId,
userRole,
});
for (const conv of jsonData) {
importBatchBuilder.startConversation(EModelEndpoint.anthropic);
let lastMessageId = Constants.NO_PARENT;
let lastTimestamp = null;
for (const msg of conv.chat_messages || []) {
const isCreatedByUser = msg.sender === 'human';
const messageId = uuidv4();
const { textContent, thinkingContent } = extractClaudeContent(msg);
// Skip empty messages
if (!textContent && !thinkingContent) {
continue;
}
// Parse timestamp, fallback to conversation create_time or current time
const messageTime = msg.created_at || conv.created_at;
let createdAt = messageTime ? new Date(messageTime) : new Date();
// Ensure timestamp is after the previous message.
// Messages are sorted by createdAt and buildTree expects parents to appear before children.
// This guards against any potential ordering issues in exports.
if (lastTimestamp && createdAt <= lastTimestamp) {
createdAt = new Date(lastTimestamp.getTime() + 1);
}
lastTimestamp = createdAt;
const message = {
messageId,
parentMessageId: lastMessageId,
text: textContent,
sender: isCreatedByUser ? 'user' : 'Claude',
isCreatedByUser,
user: requestUserId,
endpoint: EModelEndpoint.anthropic,
createdAt,
};
// Add content array with thinking if present
if (thinkingContent && !isCreatedByUser) {
message.content = [
{ type: 'think', think: thinkingContent },
{ type: 'text', text: textContent },
];
}
importBatchBuilder.saveMessage(message);
lastMessageId = messageId;
}
const createdAt = conv.created_at ? new Date(conv.created_at) : new Date();
importBatchBuilder.finishConversation(
conv.name || 'Imported Claude Chat',
createdAt,
{},
defaultModel,
);
}
await importBatchBuilder.saveBatch();
logger.info(`user: ${requestUserId} | Claude conversation imported`);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from Claude file`, error);
throw error;
}
}
/**
* Imports a LibreChat conversation from JSON.
*
* @param {Object} jsonData - The JSON data representing the conversation.
* @param {string} requestUserId - The ID of the user making the import request.
* @param {Function} [builderFactory=createImportBatchBuilder] - The factory function to create an import batch builder.
* @returns {Promise<void>} - A promise that resolves when the import is complete.
*/
async function importLibreChatConvo(
jsonData,
requestUserId,
builderFactory = createImportBatchBuilder,
userRole,
) {
try {
/** @type {ImportBatchBuilder} */
const importBatchBuilder = builderFactory(requestUserId);
const options = jsonData.options || {};
/* Endpoint configuration */
let endpoint = jsonData.endpoint ?? options.endpoint ?? EModelEndpoint.openAI;
const endpointsConfig = await getEndpointsConfig({
user: { id: requestUserId, role: userRole, tenantId: getTenantId() },
});
const endpointConfig = endpointsConfig?.[endpoint];
if (!endpointConfig && endpointsConfig) {
endpoint = Object.keys(endpointsConfig)[0];
} else if (!endpointConfig) {
endpoint = EModelEndpoint.openAI;
}
importBatchBuilder.startConversation(endpoint);
const defaultModel = await resolveImportDefaultModel({
endpoint,
requestUserId,
userRole,
});
let firstMessageDate = null;
const messagesToImport = jsonData.messagesTree || jsonData.messages;
if (jsonData.recursive) {
/**
* Flatten the recursive message tree into a flat array
* @param {TMessage[]} messages
* @param {string} parentMessageId
* @param {TMessage[]} flatMessages
*/
const flattenMessages = (
messages,
parentMessageId = Constants.NO_PARENT,
flatMessages = [],
) => {
for (const message of messages) {
if (!message.text && !message.content) {
continue;
}
const flatMessage = {
...message,
parentMessageId: parentMessageId,
children: undefined, // Remove children from flat structure
};
flatMessages.push(flatMessage);
if (!firstMessageDate && message.createdAt) {
firstMessageDate = new Date(message.createdAt);
}
if (message.children && message.children.length > 0) {
flattenMessages(message.children, message.messageId, flatMessages);
}
}
return flatMessages;
};
const flatMessages = flattenMessages(messagesToImport);
cloneMessagesWithTimestamps(flatMessages, importBatchBuilder);
} else if (messagesToImport) {
cloneMessagesWithTimestamps(messagesToImport, importBatchBuilder);
for (const message of messagesToImport) {
if (!firstMessageDate && message.createdAt) {
firstMessageDate = new Date(message.createdAt);
}
}
} else {
throw new Error('Invalid LibreChat file format');
}
if (firstMessageDate === 'Invalid Date') {
firstMessageDate = null;
}
importBatchBuilder.finishConversation(
jsonData.title,
firstMessageDate ?? new Date(),
options,
defaultModel,
);
await importBatchBuilder.saveBatch();
logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from LibreChat file`, error);
throw error;
}
}
/**
* Imports ChatGPT conversations from provided JSON data.
* Initializes the import process by creating a batch builder and processing each conversation in the data.
*
* @param {ChatGPTConvo[]} jsonData - Array of conversation objects to be imported.
* @param {string} requestUserId - The ID of the user who initiated the import process.
* @param {Function} builderFactory - Factory function to create a new import batch builder instance, defaults to createImportBatchBuilder.
* @returns {Promise<void>} Promise that resolves when all conversations have been imported.
*/
async function importChatGptConvo(
jsonData,
requestUserId,
builderFactory = createImportBatchBuilder,
userRole,
) {
try {
const importBatchBuilder = builderFactory(requestUserId);
const defaultModel = await resolveImportDefaultModel({
endpoint: EModelEndpoint.openAI,
requestUserId,
userRole,
});
for (const conv of jsonData) {
processConversation(conv, importBatchBuilder, requestUserId, defaultModel);
}
await importBatchBuilder.saveBatch();
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from imported file`, error);
throw error;
}
}
/**
* Processes a single conversation, adding messages to the batch builder based on author roles and handling text content.
* It directly manages the addition of messages for different roles and handles citations for assistant messages.
*
* @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details.
* @param {ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data.
* @param {string} requestUserId - The ID of the user who initiated the import process.
* @param {string} [defaultModel] - Resolved default model for the openAI endpoint.
* @returns {void}
*/
function processConversation(conv, importBatchBuilder, requestUserId, defaultModel) {
importBatchBuilder.startConversation(EModelEndpoint.openAI);
// Map all message IDs to new UUIDs
const messageMap = new Map();
for (const [id, mapping] of Object.entries(conv.mapping)) {
if (mapping.message?.content?.content_type) {
const newMessageId = uuidv4();
messageMap.set(id, newMessageId);
}
}
/**
* Finds the nearest valid parent by traversing up through skippable messages
* (system, reasoning_recap, thoughts). Uses iterative traversal to avoid
* stack overflow on deep chains of skippable messages.
*
* @param {string} startId - The ID of the starting parent message.
* @returns {string} The ID of the nearest valid parent message.
*/
const findValidParent = (startId) => {
const visited = new Set();
let parentId = startId;
while (parentId) {
if (!messageMap.has(parentId) || visited.has(parentId)) {
return Constants.NO_PARENT;
}
visited.add(parentId);
const parentMapping = conv.mapping[parentId];
if (!parentMapping?.message) {
return Constants.NO_PARENT;
}
const contentType = parentMapping.message.content?.content_type;
const shouldSkip =
parentMapping.message.author?.role === 'system' ||
contentType === 'reasoning_recap' ||
contentType === 'thoughts';
if (!shouldSkip) {
return messageMap.get(parentId);
}
parentId = parentMapping.parent;
}
return Constants.NO_PARENT;
};
/**
* Helper function to find thinking content from parent chain (thoughts messages)
* @param {string} parentId - The ID of the parent message.
* @param {Set} visited - Set of already-visited IDs to prevent cycles.
* @returns {Array} The thinking content array (empty if not found).
*/
const findThinkingContent = (parentId, visited = new Set()) => {
// Guard against circular references in malformed imports
if (!parentId || visited.has(parentId)) {
return [];
}
visited.add(parentId);
const parentMapping = conv.mapping[parentId];
if (!parentMapping?.message) {
return [];
}
const contentType = parentMapping.message.content?.content_type;
// If this is a thoughts message, extract the thinking content
if (contentType === 'thoughts') {
const thoughts = parentMapping.message.content.thoughts || [];
const thinkingText = thoughts
.map((t) => t.content || t.summary || '')
.filter(Boolean)
.join('\n\n');
if (thinkingText) {
return [{ type: 'think', think: thinkingText }];
}
return [];
}
// If this is reasoning_recap, look at its parent for thoughts
if (contentType === 'reasoning_recap') {
return findThinkingContent(parentMapping.parent, visited);
}
return [];
};
// Create and save messages using the mapped IDs
const messages = [];
for (const [id, mapping] of Object.entries(conv.mapping)) {
const role = mapping.message?.author?.role;
if (!mapping.message) {
messageMap.delete(id);
continue;
} else if (role === 'system') {
// Skip system messages but keep their ID in messageMap for parent references
continue;
}
const contentType = mapping.message.content?.content_type;
// Skip thoughts messages - they will be merged into the response message
if (contentType === 'thoughts') {
continue;
}
// Skip reasoning_recap messages (just summaries like "Thought for 44s")
if (contentType === 'reasoning_recap') {
continue;
}
const newMessageId = messageMap.get(id);
if (!newMessageId) {
continue;
}
const parentMessageId = findValidParent(mapping.parent);
const messageText = formatMessageText(mapping.message);
const isCreatedByUser = role === 'user';
let sender = isCreatedByUser ? 'user' : 'assistant';
const model =
mapping.message.metadata?.model_slug || defaultModel || openAISettings.model.default;
if (!isCreatedByUser) {
/** Extracted model name from model slug */
const gptMatch = model.match(/gpt-(.+)/i);
if (gptMatch) {
sender = `GPT-${gptMatch[1]}`;
} else {
sender = model || 'assistant';
}
}
// Use create_time from ChatGPT export to ensure proper message ordering
// For null timestamps, use the conversation's create_time as fallback, or current time as last resort
const messageTime = mapping.message.create_time || conv.create_time;
const createdAt = messageTime ? new Date(messageTime * 1000) : new Date();
const message = {
messageId: newMessageId,
parentMessageId,
text: messageText,
sender,
isCreatedByUser,
model,
user: requestUserId,
endpoint: EModelEndpoint.openAI,
createdAt,
};
// For assistant messages, check if there's thinking content in the parent chain
if (!isCreatedByUser) {
const thinkingContent = findThinkingContent(mapping.parent);
if (thinkingContent.length > 0) {
// Combine thinking content with the text response
message.content = [...thinkingContent, { type: 'text', text: messageText }];
}
}
messages.push(message);
}
const cycleDetected = adjustTimestampsForOrdering(messages);
if (cycleDetected) {
breakParentCycles(messages);
}
for (const message of messages) {
importBatchBuilder.saveMessage(message);
}
importBatchBuilder.finishConversation(
conv.title,
new Date(conv.create_time * 1000),
{},
defaultModel,
);
}
/**
* Processes text content of messages authored by an assistant, inserting citation links as required.
* Uses citation start and end indices to place links at the correct positions.
*
* @param {ChatGPTMessage} messageData - The message data containing metadata about citations.
* @param {string} messageText - The original text of the message which may be altered by inserting citation links.
* @returns {string} - The updated message text after processing for citations.
*/
function processAssistantMessage(messageData, messageText) {
if (!messageText) {
return messageText;
}
const citations = messageData.metadata?.citations ?? [];
const sortedCitations = [...citations].sort((a, b) => b.start_ix - a.start_ix);
let result = messageText;
for (const citation of sortedCitations) {
if (
!citation.metadata?.type ||
citation.metadata.type !== 'webpage' ||
typeof citation.start_ix !== 'number' ||
typeof citation.end_ix !== 'number' ||
citation.start_ix >= citation.end_ix
) {
continue;
}
const replacement = ` ([${citation.metadata.title}](${citation.metadata.url}))`;
result = result.slice(0, citation.start_ix) + replacement + result.slice(citation.end_ix);
}
return result;
}
/**
* Formats the text content of a message based on its content type and author role.
* @param {ChatGPTMessage} messageData - The message data.
* @returns {string} - The formatted message text.
*/
function formatMessageText(messageData) {
const contentType = messageData.content.content_type;
const isText = contentType === 'text';
let messageText = '';
if (isText && messageData.content.parts) {
messageText = messageData.content.parts.join(' ');
} else if (contentType === 'code') {
messageText = `\`\`\`${messageData.content.language}\n${messageData.content.text}\n\`\`\``;
} else if (contentType === 'execution_output') {
messageText = `Execution Output:\n> ${messageData.content.text}`;
} else if (messageData.content.parts) {
for (const part of messageData.content.parts) {
if (typeof part === 'string') {
messageText += part + ' ';
} else if (typeof part === 'object') {
messageText = `\`\`\`json\n${JSON.stringify(part, null, 2)}\n\`\`\`\n`;
}
}
messageText = messageText.trim();
} else {
messageText = `\`\`\`json\n${JSON.stringify(messageData.content, null, 2)}\n\`\`\``;
}
if (isText && messageData.author?.role !== 'user') {
messageText = processAssistantMessage(messageData, messageText);
}
return messageText;
}
/**
* Adjusts message timestamps to ensure children always come after parents.
* Messages are sorted by createdAt and buildTree expects parents to appear before children.
* ChatGPT exports can have slight timestamp inversions (e.g., tool call results
* arriving a few ms before their parent). Uses multiple passes to handle cascading adjustments.
* Capped at N passes (where N = message count) to guarantee termination on cyclic graphs.
*
* @param {Array} messages - Array of message objects with messageId, parentMessageId, and createdAt.
* @returns {boolean} True if cyclic parent relationships were detected.
*/
function adjustTimestampsForOrdering(messages) {
if (messages.length === 0) {
return false;
}
const timestampMap = new Map();
for (const msg of messages) {
timestampMap.set(msg.messageId, msg.createdAt);
}
let hasChanges = true;
let remainingPasses = messages.length;
while (hasChanges && remainingPasses > 0) {
hasChanges = false;
remainingPasses--;
for (const message of messages) {
if (message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT) {
const parentTimestamp = timestampMap.get(message.parentMessageId);
if (parentTimestamp && message.createdAt <= parentTimestamp) {
message.createdAt = new Date(parentTimestamp.getTime() + 1);
timestampMap.set(message.messageId, message.createdAt);
hasChanges = true;
}
}
}
}
const cycleDetected = remainingPasses === 0 && hasChanges;
if (cycleDetected) {
logger.warn(
'[importers] Detected cyclic parent relationships while adjusting import timestamps',
);
}
return cycleDetected;
}
/**
* Severs cyclic parentMessageId back-edges so saved messages form a valid tree.
* Walks each message's parent chain; if a message is visited twice, its parentMessageId
* is set to NO_PARENT to break the cycle.
*
* @param {Array} messages - Array of message objects with messageId and parentMessageId.
*/
function breakParentCycles(messages) {
const parentLookup = new Map();
for (const msg of messages) {
parentLookup.set(msg.messageId, msg);
}
const settled = new Set();
for (const message of messages) {
const chain = new Set();
let current = message;
while (current && !settled.has(current.messageId)) {
if (chain.has(current.messageId)) {
current.parentMessageId = Constants.NO_PARENT;
break;
}
chain.add(current.messageId);
const parentId = current.parentMessageId;
if (!parentId || parentId === Constants.NO_PARENT) {
break;
}
current = parentLookup.get(parentId);
}
for (const id of chain) {
settled.add(id);
}
}
}
module.exports = { getImporter, processAssistantMessage };
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const importers = require('./importers');
const importConversations = require('./importConversations');
module.exports = {
...importers,
importConversations,
};
+11
View File
@@ -0,0 +1,11 @@
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
const files = require('./files');
module.exports = {
...handleText,
sendEmail,
...files,
...queue,
};
+58
View File
@@ -0,0 +1,58 @@
/**
* A leaky bucket queue structure to manage API requests.
* @type {{queue: Array, interval: NodeJS.Timer | null}}
*/
const _LB = {
queue: [],
interval: null,
};
/**
* Interval in milliseconds to control the rate of API requests.
* Adjust the interval according to your rate limit needs.
*/
const _LB_INTERVAL_MS = Math.ceil(1000 / 60); // 60 req/s
/**
* Executes the next function in the leaky bucket queue.
* This function is called at regular intervals defined by _LB_INTERVAL_MS.
*/
const _LB_EXEC_NEXT = async () => {
if (_LB.queue.length === 0) {
clearInterval(_LB.interval);
_LB.interval = null;
return;
}
const next = _LB.queue.shift();
if (!next) {
return;
}
const { asyncFunc, args, callback } = next;
try {
const data = await asyncFunc(...args);
callback(null, data);
} catch (e) {
callback(e);
}
};
/**
* Adds an async function call to the leaky bucket queue.
* @param {Function} asyncFunc - The async function to be executed.
* @param {Array} args - Arguments to pass to the async function.
* @param {Function} callback - Callback function for handling the result or error.
*/
function LB_QueueAsyncCall(asyncFunc, args, callback) {
_LB.queue.push({ asyncFunc, args, callback });
if (_LB.interval === null) {
_LB.interval = setInterval(_LB_EXEC_NEXT, _LB_INTERVAL_MS);
}
}
module.exports = {
LB_QueueAsyncCall,
};
+179
View File
@@ -0,0 +1,179 @@
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const nodemailer = require('nodemailer');
const handlebars = require('handlebars');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, isEnabled, readFileAsString } = require('@librechat/api');
/**
* Sends an email using Mailgun API.
*
* @async
* @function sendEmailViaMailgun
* @param {Object} params - The parameters for sending the email.
* @param {string} params.to - The recipient's email address.
* @param {string} params.from - The sender's email address.
* @param {string} params.subject - The subject of the email.
* @param {string} params.html - The HTML content of the email.
* @returns {Promise<Object>} - A promise that resolves to the response from Mailgun API.
*/
const sendEmailViaMailgun = async ({ to, from, subject, html }) => {
const mailgunApiKey = process.env.MAILGUN_API_KEY;
const mailgunDomain = process.env.MAILGUN_DOMAIN;
const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net';
if (!mailgunApiKey || !mailgunDomain) {
throw new Error('Mailgun API key and domain are required');
}
const formData = new FormData();
formData.append('from', from);
formData.append('to', to);
formData.append('subject', subject);
formData.append('html', html);
formData.append('o:tracking-clicks', 'no');
try {
const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
headers: {
...formData.getHeaders(),
Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
},
});
return response.data;
} catch (error) {
throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
}
};
/**
* Sends an email using SMTP via Nodemailer.
*
* @async
* @function sendEmailViaSMTP
* @param {Object} params - The parameters for sending the email.
* @param {Object} params.transporterOptions - The transporter configuration options.
* @param {Object} params.mailOptions - The email options.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email.
*/
const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => {
const transporter = nodemailer.createTransport(transporterOptions);
return await transporter.sendMail(mailOptions);
};
/**
* Sends an email using the specified template, subject, and payload.
*
* @async
* @function sendEmail
* @param {Object} params - The parameters for sending the email.
* @param {string} params.email - The recipient's email address.
* @param {string} params.subject - The subject of the email.
* @param {Record<string, string>} params.payload - The data to be used in the email template.
* @param {string} params.template - The filename of the email template.
* @param {boolean} [throwError=true] - Whether to throw an error if the email sending process fails.
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email or the error if sending the email fails.
*
* @example
* const emailData = {
* email: 'recipient@example.com',
* subject: 'Welcome!',
* payload: { name: 'Recipient' },
* template: 'welcome.html'
* };
*
* sendEmail(emailData)
* .then(info => console.log('Email sent:', info))
* .catch(error => console.error('Error sending email:', error));
*
* @throws Will throw an error if the email sending process fails and throwError is `true`.
*/
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
try {
const { content: source } = await readFileAsString(path.join(__dirname, 'emails', template));
const compiledTemplate = handlebars.compile(source);
const html = compiledTemplate(payload);
// Prepare common email data
const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
const fromEmail = process.env.EMAIL_FROM;
const fromAddress = `"${fromName}" <${fromEmail}>`;
const toAddress = `"${payload.name}" <${email}>`;
// Check if Mailgun is configured
if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
logger.debug('[sendEmail] Using Mailgun provider');
return await sendEmailViaMailgun({
from: fromAddress,
to: toAddress,
subject: subject,
html: html,
});
}
// Default to SMTP
logger.debug('[sendEmail] Using SMTP provider');
const transporterOptions = {
// Use STARTTLS by default instead of obligatory TLS
secure: process.env.EMAIL_ENCRYPTION === 'tls',
// If explicit STARTTLS is set, require it when connecting
requireTls: process.env.EMAIL_ENCRYPTION === 'starttls',
tls: {
// Whether to accept unsigned certificates
rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED),
},
};
const hasUsername = !!process.env.EMAIL_USERNAME;
const hasPassword = !!process.env.EMAIL_PASSWORD;
if (hasUsername && hasPassword) {
transporterOptions.auth = {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
};
} else if (hasUsername !== hasPassword) {
logger.warn(
'[sendEmail] EMAIL_USERNAME and EMAIL_PASSWORD must both be set for authenticated SMTP, or both omitted for unauthenticated SMTP. Proceeding without authentication.',
);
}
if (process.env.EMAIL_ENCRYPTION_HOSTNAME) {
// Check the certificate against this name explicitly
transporterOptions.tls.servername = process.env.EMAIL_ENCRYPTION_HOSTNAME;
}
// Mailer service definition has precedence
if (process.env.EMAIL_SERVICE) {
transporterOptions.service = process.env.EMAIL_SERVICE;
} else {
transporterOptions.host = process.env.EMAIL_HOST;
transporterOptions.port = process.env.EMAIL_PORT ?? 25;
}
const mailOptions = {
// Header address should contain name-addr
from: fromAddress,
to: toAddress,
envelope: {
// Envelope from should contain addr-spec
// Mistake in the Nodemailer documentation?
from: fromEmail,
to: email,
},
subject: subject,
html: html,
};
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
} catch (error) {
if (throwError) {
throw error;
}
logger.error('[sendEmail]', error);
return error;
}
};
module.exports = sendEmail;
+65
View File
@@ -0,0 +1,65 @@
const path = require('path');
const express = require('express');
const expressStaticGzip = require('express-static-gzip');
const oneDayInSeconds = 24 * 60 * 60;
const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds;
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
const isEnabled = (value) => value === true || String(value).toLowerCase() === 'true';
/**
* Creates an Express static middleware with optional precompressed asset serving and configurable caching
*
* @param {string} staticPath - The file system path to serve static files from
* @param {Object} [options={}] - Configuration options
* @param {boolean} [options.noCache=false] - If true, disables caching entirely for all files
* @param {boolean} [options.skipGzipScan=false] - If true, skips expressStaticGzip middleware
* @returns {ReturnType<expressStaticGzip>|ReturnType<express.static>} Express middleware function for serving static files
*/
function staticCache(staticPath, options = {}) {
const { noCache = false, skipGzipScan = false } = options;
const enableBrotli = isEnabled(process.env.ENABLE_STATIC_ASSET_BROTLI);
const setHeaders = (res, filePath) => {
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
return;
}
if (noCache) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
return;
}
if (filePath && filePath.includes('/dist/images/')) {
return;
}
const fileName = filePath ? path.basename(filePath) : '';
if (
fileName === 'index.html' ||
fileName.endsWith('.webmanifest') ||
fileName === 'manifest.json' ||
fileName === 'sw.js' ||
fileName === 'sw-heal.js'
) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
} else {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
}
};
if (skipGzipScan) {
return express.static(staticPath, {
setHeaders,
index: false,
});
} else {
return expressStaticGzip(staticPath, {
enableBrotli,
orderPreference: enableBrotli ? ['br', 'gz'] : ['gz'],
setHeaders,
index: false,
});
}
}
module.exports = staticCache;