mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔥🚀 feat: CDN (Firebase) & feat: account section (#1438)
* localization + api-endpoint * docs: added firebase documentation * chore: icons * chore: SettingsTabs * feat: account pannel; fix: gear icons * docs: position update * feat: firebase * feat: plugin support * route * fixed bugs with firebase and moved a lot of files * chore(DALLE3): using UUID v4 * feat: support for social strategies; moved '/images' path * fix: data ignored * gitignore update * docs: update firebase guide * refactor: Firebase - use singleton pattern for firebase initialization, initially on server start - reorganize imports, move firebase specific files to own service under Files - rename modules to remove 'avatar' redundancy - fix imports based on changes * ci(DALLE/DALLE3): fix tests to use logger and new expected outputs, add firebase tests * refactor(loadToolWithAuth): pass userId to tool as field * feat(images/parse): feat: Add URL Image Basename Extraction Implement a new module to extract the basename of an image from a given URL. This addition includes the function, which parses the URL and retrieves the basename using the Node.js 'url' and 'path' modules. The function is documented with JSDoc comments for better maintainability and understanding. This feature enhances the application's ability to handle and process image URLs efficiently. * refactor(addImages): function to use a more specific regular expression for observedImagePath based on the generated image markdown standard across the app * refactor(DALLE/DALLE3): utilize `getImageBasename` and `this.userId`; fix: pass correct image path to firebase url helper * fix(addImages): make more general to match any image markdown descriptor * fix(parse/getImageBasename): test result of this function for an actual image basename * ci(DALLE3): mock getImageBasename * refactor(AuthContext): use Recoil atom state for user * feat: useUploadAvatarMutation, react-query hook for avatar upload * fix(Toast): stack z-order of Toast over all components (1000) * refactor(showToast): add optional status field to avoid importing NotificationSeverity on each use of the function * refactor(routes/avatar): remove unnecessary get route, get userId from req.user.id, require auth on POST request * chore(uploadAvatar): TODO: remove direct use of Model, `User` * fix(client): fix Spinner imports * refactor(Avatar): use react-query hook, Toast, remove unnecessary states, add optimistic UI to upload * fix(avatar/localStrategy): correctly save local profile picture and cache bust for immediate rendering; fix: firebase init info message (only show once) * fix: use `includes` instead of `endsWith` for checking manual query of avatar image path in case more queries are appended (as is done in avatar/localStrategy) --------- Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
parent
bd4d23d314
commit
f19f5dca8e
59 changed files with 1855 additions and 172 deletions
11
.env.example
11
.env.example
|
@ -281,6 +281,17 @@ EMAIL_PASSWORD=
|
|||
EMAIL_FROM_NAME=
|
||||
EMAIL_FROM=noreply@librechat.ai
|
||||
|
||||
#========================#
|
||||
# Firebase CDN #
|
||||
#========================#
|
||||
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_AUTH_DOMAIN=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_STORAGE_BUCKET=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_APP_ID=
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -83,3 +83,5 @@ auth.json
|
|||
|
||||
/packages/ux-shared/
|
||||
/images
|
||||
|
||||
!client/src/components/Nav/SettingsTabs/Data/
|
|
@ -60,13 +60,11 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
if (!observation || !observation.includes('![')) {
|
||||
return;
|
||||
}
|
||||
const observedImagePath = observation.match(/\(\/images\/.*\.\w*\)/g);
|
||||
const observedImagePath = observation.match(/!\[.*\]\([^)]*\)/g);
|
||||
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
|
||||
responseMessage.text += '\n' + observation;
|
||||
if (process.env.DEBUG_PLUGINS) {
|
||||
logger.debug('[addImages] added image from intermediateSteps:', observation);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,15 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
// const { genAzureEndpoint } = require('~/utils/genAzureEndpoints');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { Tool } = require('langchain/tools');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const {
|
||||
saveImageToFirebaseStorage,
|
||||
getFirebaseStorageImageUrl,
|
||||
getFirebaseStorage,
|
||||
} = require('~/server/services/Files/Firebase');
|
||||
const { getImageBasename } = require('~/server/services/Files/images');
|
||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
||||
const saveImageFromUrl = require('./saveImageFromUrl');
|
||||
const { logger } = require('~/config');
|
||||
|
@ -15,7 +22,9 @@ class OpenAICreateImage extends Tool {
|
|||
constructor(fields = {}) {
|
||||
super();
|
||||
|
||||
this.userId = fields.userId;
|
||||
let apiKey = fields.DALLE_API_KEY || this.getApiKey();
|
||||
|
||||
const config = { apiKey };
|
||||
if (DALLE_REVERSE_PROXY) {
|
||||
config.baseURL = extractBaseURL(DALLE_REVERSE_PROXY);
|
||||
|
@ -24,7 +33,6 @@ class OpenAICreateImage extends Tool {
|
|||
if (PROXY) {
|
||||
config.httpAgent = new HttpsProxyAgent(PROXY);
|
||||
}
|
||||
|
||||
// let azureKey = fields.AZURE_API_KEY || process.env.AZURE_API_KEY;
|
||||
|
||||
// if (azureKey) {
|
||||
|
@ -97,12 +105,11 @@ Guidelines:
|
|||
throw new Error('No image URL returned from OpenAI API.');
|
||||
}
|
||||
|
||||
const regex = /img-[\w\d]+.png/;
|
||||
const match = theImageUrl.match(regex);
|
||||
let imageName = '1.png';
|
||||
const imageBasename = getImageBasename(theImageUrl);
|
||||
let imageName = `image_${uuidv4()}.png`;
|
||||
|
||||
if (match) {
|
||||
imageName = match[0];
|
||||
if (imageBasename) {
|
||||
imageName = imageBasename;
|
||||
logger.debug('[DALL-E]', { imageName }); // Output: img-lgCf7ppcbhqQrz6a5ear6FOb.png
|
||||
} else {
|
||||
logger.debug('[DALL-E] No image name found in the string.', {
|
||||
|
@ -111,7 +118,18 @@ Guidelines:
|
|||
});
|
||||
}
|
||||
|
||||
this.outputPath = path.resolve(__dirname, '..', '..', '..', '..', 'client', 'public', 'images');
|
||||
this.outputPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client',
|
||||
'public',
|
||||
'images',
|
||||
this.userId,
|
||||
);
|
||||
|
||||
const appRoot = path.resolve(__dirname, '..', '..', '..', '..', 'client');
|
||||
this.relativeImageUrl = path.relative(appRoot, this.outputPath);
|
||||
|
||||
|
@ -120,14 +138,25 @@ Guidelines:
|
|||
fs.mkdirSync(this.outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = getFirebaseStorage();
|
||||
if (storage) {
|
||||
try {
|
||||
await saveImageToFirebaseStorage(this.userId, theImageUrl, imageName);
|
||||
this.result = await getFirebaseStorageImageUrl(`${this.userId}/${imageName}`);
|
||||
logger.debug('[DALL-E] result: ' + this.result);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image to Firebase Storage:', error);
|
||||
this.result = `Failed to save the image to Firebase Storage. ${error.message}`;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await saveImageFromUrl(theImageUrl, this.outputPath, imageName);
|
||||
this.result = this.getMarkdownImageUrl(imageName);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the DALL-E image:', error);
|
||||
this.result = theImageUrl;
|
||||
logger.error('Error while saving the image locally:', error);
|
||||
this.result = `Failed to save the image locally. ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,17 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const { z } = require('zod');
|
||||
const OpenAI = require('openai');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { Tool } = require('langchain/tools');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const saveImageFromUrl = require('../saveImageFromUrl');
|
||||
const {
|
||||
saveImageToFirebaseStorage,
|
||||
getFirebaseStorageImageUrl,
|
||||
getFirebaseStorage,
|
||||
} = require('~/server/services/Files/Firebase');
|
||||
const { getImageBasename } = require('~/server/services/Files/images');
|
||||
const extractBaseURL = require('~/utils/extractBaseURL');
|
||||
const saveImageFromUrl = require('../saveImageFromUrl');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const { DALLE3_SYSTEM_PROMPT, DALLE_REVERSE_PROXY, PROXY } = process.env;
|
||||
|
@ -15,6 +22,7 @@ class DALLE3 extends Tool {
|
|||
constructor(fields = {}) {
|
||||
super();
|
||||
|
||||
this.userId = fields.userId;
|
||||
let apiKey = fields.DALLE_API_KEY || this.getApiKey();
|
||||
const config = { apiKey };
|
||||
if (DALLE_REVERSE_PROXY) {
|
||||
|
@ -108,12 +116,12 @@ class DALLE3 extends Tool {
|
|||
n: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
return `Something went wrong when trying to generate the image. The DALL-E API may unavailable:
|
||||
return `Something went wrong when trying to generate the image. The DALL-E API may be unavailable:
|
||||
Error Message: ${error.message}`;
|
||||
}
|
||||
|
||||
if (!resp) {
|
||||
return 'Something went wrong when trying to generate the image. The DALL-E API may unavailable';
|
||||
return 'Something went wrong when trying to generate the image. The DALL-E API may be unavailable';
|
||||
}
|
||||
|
||||
const theImageUrl = resp.data[0].url;
|
||||
|
@ -122,12 +130,11 @@ Error Message: ${error.message}`;
|
|||
return 'No image URL returned from OpenAI API. There may be a problem with the API or your configuration.';
|
||||
}
|
||||
|
||||
const regex = /img-[\w\d]+.png/;
|
||||
const match = theImageUrl.match(regex);
|
||||
let imageName = '1.png';
|
||||
const imageBasename = getImageBasename(theImageUrl);
|
||||
let imageName = `image_${uuidv4()}.png`;
|
||||
|
||||
if (match) {
|
||||
imageName = match[0];
|
||||
if (imageBasename) {
|
||||
imageName = imageBasename;
|
||||
logger.debug('[DALL-E-3]', { imageName }); // Output: img-lgCf7ppcbhqQrz6a5ear6FOb.png
|
||||
} else {
|
||||
logger.debug('[DALL-E-3] No image name found in the string.', {
|
||||
|
@ -146,6 +153,7 @@ Error Message: ${error.message}`;
|
|||
'client',
|
||||
'public',
|
||||
'images',
|
||||
this.userId,
|
||||
);
|
||||
const appRoot = path.resolve(__dirname, '..', '..', '..', '..', '..', 'client');
|
||||
this.relativeImageUrl = path.relative(appRoot, this.outputPath);
|
||||
|
@ -154,13 +162,24 @@ Error Message: ${error.message}`;
|
|||
if (!fs.existsSync(this.outputPath)) {
|
||||
fs.mkdirSync(this.outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = getFirebaseStorage();
|
||||
if (storage) {
|
||||
try {
|
||||
await saveImageToFirebaseStorage(this.userId, theImageUrl, imageName);
|
||||
this.result = await getFirebaseStorageImageUrl(`${this.userId}/${imageName}`);
|
||||
logger.debug('[DALL-E-3] result: ' + this.result);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image to Firebase Storage:', error);
|
||||
this.result = `Failed to save the image to Firebase Storage. ${error.message}`;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await saveImageFromUrl(theImageUrl, this.outputPath, imageName);
|
||||
this.result = this.getMarkdownImageUrl(imageName);
|
||||
} catch (error) {
|
||||
logger.error('Error while saving the image:', error);
|
||||
this.result = theImageUrl;
|
||||
logger.error('Error while saving the image locally:', error);
|
||||
this.result = `Failed to save the image locally. ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return this.result;
|
||||
|
|
|
@ -2,11 +2,40 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
const DALLE3 = require('../DALLE3');
|
||||
const {
|
||||
getFirebaseStorage,
|
||||
saveImageToFirebaseStorage,
|
||||
} = require('~/server/services/Files/Firebase');
|
||||
const saveImageFromUrl = require('../../saveImageFromUrl');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
jest.mock('~/server/services/Files/Firebase', () => ({
|
||||
getFirebaseStorage: jest.fn(),
|
||||
saveImageToFirebaseStorage: jest.fn(),
|
||||
getFirebaseStorageImageUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images', () => ({
|
||||
getImageBasename: jest.fn().mockImplementation((url) => {
|
||||
// Split the URL by '/'
|
||||
const parts = url.split('/');
|
||||
|
||||
// Get the last part of the URL
|
||||
const lastPart = parts.pop();
|
||||
|
||||
// Check if the last part of the URL matches the image extension regex
|
||||
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i;
|
||||
if (imageExtensionRegex.test(lastPart)) {
|
||||
return lastPart;
|
||||
}
|
||||
|
||||
// If the regex test fails, return an empty string
|
||||
return '';
|
||||
}),
|
||||
}));
|
||||
|
||||
const generate = jest.fn();
|
||||
OpenAI.mockImplementation(() => ({
|
||||
images: {
|
||||
|
@ -187,7 +216,48 @@ describe('DALLE3', () => {
|
|||
generate.mockResolvedValue(mockResponse);
|
||||
saveImageFromUrl.mockRejectedValue(error);
|
||||
const result = await dalle._call(mockData);
|
||||
expect(logger.error).toHaveBeenCalledWith('Error while saving the image:', error);
|
||||
expect(result).toBe(mockResponse.data[0].url);
|
||||
expect(logger.error).toHaveBeenCalledWith('Error while saving the image locally:', error);
|
||||
expect(result).toBe('Failed to save the image locally. Error while saving the image');
|
||||
});
|
||||
|
||||
it('should save image to Firebase Storage if Firebase is initialized', async () => {
|
||||
const mockData = {
|
||||
prompt: 'A test prompt',
|
||||
};
|
||||
const mockImageUrl = 'http://example.com/img-test.png';
|
||||
const mockResponse = { data: [{ url: mockImageUrl }] };
|
||||
generate.mockResolvedValue(mockResponse);
|
||||
getFirebaseStorage.mockReturnValue({}); // Simulate Firebase being initialized
|
||||
|
||||
await dalle._call(mockData);
|
||||
|
||||
expect(getFirebaseStorage).toHaveBeenCalled();
|
||||
expect(saveImageToFirebaseStorage).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
mockImageUrl,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error when saving image to Firebase Storage fails', async () => {
|
||||
const mockData = {
|
||||
prompt: 'A test prompt',
|
||||
};
|
||||
const mockImageUrl = 'http://example.com/img-test.png';
|
||||
const mockResponse = { data: [{ url: mockImageUrl }] };
|
||||
const error = new Error('Error while saving to Firebase');
|
||||
generate.mockResolvedValue(mockResponse);
|
||||
getFirebaseStorage.mockReturnValue({}); // Simulate Firebase being initialized
|
||||
saveImageToFirebaseStorage.mockRejectedValue(error);
|
||||
|
||||
const result = await dalle._call(mockData);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error while saving the image to Firebase Storage:',
|
||||
error,
|
||||
);
|
||||
expect(result).toBe(
|
||||
'Failed to save the image to Firebase Storage. Error while saving to Firebase',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -67,19 +67,19 @@ const validateTools = async (user, tools = []) => {
|
|||
}
|
||||
};
|
||||
|
||||
const loadToolWithAuth = async (user, authFields, ToolConstructor, options = {}) => {
|
||||
const loadToolWithAuth = async (userId, authFields, ToolConstructor, options = {}) => {
|
||||
return async function () {
|
||||
let authValues = {};
|
||||
|
||||
for (const authField of authFields) {
|
||||
let authValue = process.env[authField];
|
||||
if (!authValue) {
|
||||
authValue = await getUserPluginAuthValue(user, authField);
|
||||
authValue = await getUserPluginAuthValue(userId, authField);
|
||||
}
|
||||
authValues[authField] = authValue;
|
||||
}
|
||||
|
||||
return new ToolConstructor({ ...options, ...authValues });
|
||||
return new ToolConstructor({ ...options, ...authValues, userId });
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"express-mongo-sanitize": "^2.2.0",
|
||||
"express-rate-limit": "^6.9.0",
|
||||
"express-session": "^1.17.3",
|
||||
"firebase": "^10.6.0",
|
||||
"googleapis": "^126.0.1",
|
||||
"handlebars": "^4.7.7",
|
||||
"html": "^1.0.0",
|
||||
|
|
|
@ -4,6 +4,7 @@ const cors = require('cors');
|
|||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { initializeFirebase } = require('~/server/services/Files/Firebase/initialize');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const { connectDb, indexSync } = require('~/lib/db');
|
||||
|
@ -23,6 +24,7 @@ const { jwtLogin, passportLogin } = require('~/strategies');
|
|||
const startServer = async () => {
|
||||
await connectDb();
|
||||
logger.info('Connected to MongoDB');
|
||||
initializeFirebase();
|
||||
await indexSync();
|
||||
|
||||
const app = express();
|
||||
|
|
34
api/server/routes/files/avatar.js
Normal file
34
api/server/routes/files/avatar.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
|
||||
const uploadAvatar = require('~/server/services/Files/images/avatar/uploadAvatar');
|
||||
const { requireJwtAuth } = require('~/server/middleware/');
|
||||
const User = require('~/models/User');
|
||||
|
||||
const upload = multer();
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', requireJwtAuth, upload.single('input'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { manual } = req.body;
|
||||
const input = req.file.buffer;
|
||||
if (!userId) {
|
||||
throw new Error('User ID is undefined');
|
||||
}
|
||||
|
||||
// TODO: do not use Model directly, instead use a service method that uses the model
|
||||
const user = await User.findById(userId).lean();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const url = await uploadAvatar(userId, input, manual);
|
||||
|
||||
res.json({ url });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'An error occurred while uploading the profile picture' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -18,5 +18,6 @@ router.use(uaParser);
|
|||
|
||||
router.use('/', files);
|
||||
router.use('/images', images);
|
||||
router.use('/images/avatar', require('./avatar'));
|
||||
|
||||
module.exports = router;
|
||||
|
|
45
api/server/services/Files/Firebase/images.js
Normal file
45
api/server/services/Files/Firebase/images.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
const fetch = require('node-fetch');
|
||||
const { ref, uploadBytes, getDownloadURL } = require('firebase/storage');
|
||||
const { getFirebaseStorage } = require('./initialize');
|
||||
|
||||
async function saveImageToFirebaseStorage(userId, imageUrl, imageName) {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
console.error('Firebase is not initialized. Cannot save image to Firebase Storage.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageRef = ref(storage, `images/${userId.toString()}/${imageName}`);
|
||||
|
||||
try {
|
||||
// Upload image to Firebase Storage using the image URL
|
||||
await uploadBytes(storageRef, await fetch(imageUrl).then((response) => response.buffer()));
|
||||
return imageName;
|
||||
} catch (error) {
|
||||
console.error('Error uploading image to Firebase Storage:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFirebaseStorageImageUrl(imageName) {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
console.error('Firebase is not initialized. Cannot get image URL from Firebase Storage.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageRef = ref(storage, `images/${imageName}`);
|
||||
|
||||
try {
|
||||
// Get the download URL for the image from Firebase Storage
|
||||
return `})`;
|
||||
} catch (error) {
|
||||
console.error('Error fetching image URL from Firebase Storage:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveImageToFirebaseStorage,
|
||||
getFirebaseStorageImageUrl,
|
||||
};
|
7
api/server/services/Files/Firebase/index.js
Normal file
7
api/server/services/Files/Firebase/index.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
const images = require('./images');
|
||||
const initialize = require('./initialize');
|
||||
|
||||
module.exports = {
|
||||
...images,
|
||||
...initialize,
|
||||
};
|
42
api/server/services/Files/Firebase/initialize.js
Normal file
42
api/server/services/Files/Firebase/initialize.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
const firebase = require('firebase/app');
|
||||
const { getStorage } = require('firebase/storage');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
let i = 0;
|
||||
let firebaseApp = null;
|
||||
|
||||
const initializeFirebase = () => {
|
||||
// Return existing instance if already initialized
|
||||
if (firebaseApp) {
|
||||
return firebaseApp;
|
||||
}
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.FIREBASE_API_KEY,
|
||||
authDomain: process.env.FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
if (Object.values(firebaseConfig).some((value) => !value)) {
|
||||
i === 0 &&
|
||||
logger.info(
|
||||
'[Optional] Firebase configuration missing or incomplete. Firebase will not be initialized.',
|
||||
);
|
||||
i++;
|
||||
return null;
|
||||
}
|
||||
|
||||
firebaseApp = firebase.initializeApp(firebaseConfig);
|
||||
logger.info('Firebase initialized');
|
||||
return firebaseApp;
|
||||
};
|
||||
|
||||
const getFirebaseStorage = () => {
|
||||
const app = initializeFirebase();
|
||||
return app ? getStorage(app) : null;
|
||||
};
|
||||
|
||||
module.exports = { initializeFirebase, getFirebaseStorage };
|
29
api/server/services/Files/images/avatar/firebaseStrategy.js
Normal file
29
api/server/services/Files/images/avatar/firebaseStrategy.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
const { ref, uploadBytes, getDownloadURL } = require('firebase/storage');
|
||||
const { getFirebaseStorage } = require('~/server/services/Files/Firebase/initialize');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
async function firebaseStrategy(userId, webPBuffer, oldUser, manual) {
|
||||
try {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
throw new Error('Firebase is not initialized.');
|
||||
}
|
||||
const avatarRef = ref(storage, `images/${userId.toString()}/avatar`);
|
||||
|
||||
await uploadBytes(avatarRef, webPBuffer);
|
||||
const urlFirebase = await getDownloadURL(avatarRef);
|
||||
const isManual = manual === 'true';
|
||||
|
||||
const url = `${urlFirebase}?manual=${isManual}`;
|
||||
if (isManual) {
|
||||
oldUser.avatar = url;
|
||||
await oldUser.save();
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error('Error uploading profile picture:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = firebaseStrategy;
|
32
api/server/services/Files/images/avatar/localStrategy.js
Normal file
32
api/server/services/Files/images/avatar/localStrategy.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
async function localStrategy(userId, webPBuffer, oldUser, manual) {
|
||||
const userDir = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client',
|
||||
'public',
|
||||
'images',
|
||||
userId,
|
||||
);
|
||||
let avatarPath = path.join(userDir, 'avatar.png');
|
||||
const urlRoute = `/images/${userId}/avatar.png`;
|
||||
await fs.mkdir(userDir, { recursive: true });
|
||||
await fs.writeFile(avatarPath, webPBuffer);
|
||||
const isManual = manual === 'true';
|
||||
let url = `${urlRoute}?manual=${isManual}×tamp=${new Date().getTime()}`;
|
||||
if (isManual) {
|
||||
oldUser.avatar = url;
|
||||
await oldUser.save();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
module.exports = localStrategy;
|
63
api/server/services/Files/images/avatar/uploadAvatar.js
Normal file
63
api/server/services/Files/images/avatar/uploadAvatar.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
const sharp = require('sharp');
|
||||
const fetch = require('node-fetch');
|
||||
const fs = require('fs').promises;
|
||||
const User = require('~/models/User');
|
||||
const { getFirebaseStorage } = require('~/server/services/Files/Firebase/initialize');
|
||||
const firebaseStrategy = require('./firebaseStrategy');
|
||||
const localStrategy = require('./localStrategy');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
async function convertToWebP(inputBuffer) {
|
||||
return sharp(inputBuffer).resize({ width: 150 }).toFormat('webp').toBuffer();
|
||||
}
|
||||
|
||||
async function uploadAvatar(userId, input, manual) {
|
||||
try {
|
||||
if (userId === undefined) {
|
||||
throw new Error('User ID is undefined');
|
||||
}
|
||||
const _id = userId;
|
||||
// TODO: remove direct use of Model, `User`
|
||||
const oldUser = await User.findOne({ _id });
|
||||
let imageBuffer;
|
||||
if (typeof input === 'string') {
|
||||
const response = await fetch(input);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
|
||||
}
|
||||
imageBuffer = await response.buffer();
|
||||
} else if (input instanceof Buffer) {
|
||||
imageBuffer = input;
|
||||
} else if (typeof input === 'object' && input instanceof File) {
|
||||
const fileContent = await fs.readFile(input.path);
|
||||
imageBuffer = Buffer.from(fileContent);
|
||||
} else {
|
||||
throw new Error('Invalid input type. Expected URL, Buffer, or File.');
|
||||
}
|
||||
const { width, height } = await sharp(imageBuffer).metadata();
|
||||
const minSize = Math.min(width, height);
|
||||
const squaredBuffer = await sharp(imageBuffer)
|
||||
.extract({
|
||||
left: Math.floor((width - minSize) / 2),
|
||||
top: Math.floor((height - minSize) / 2),
|
||||
width: minSize,
|
||||
height: minSize,
|
||||
})
|
||||
.toBuffer();
|
||||
const webPBuffer = await convertToWebP(squaredBuffer);
|
||||
const storage = getFirebaseStorage();
|
||||
if (storage) {
|
||||
const url = await firebaseStrategy(userId, webPBuffer, oldUser, manual);
|
||||
return url;
|
||||
}
|
||||
|
||||
const url = await localStrategy(userId, webPBuffer, oldUser, manual);
|
||||
return url;
|
||||
} catch (error) {
|
||||
logger.error('Error uploading the avatar:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = uploadAvatar;
|
|
@ -1,11 +1,15 @@
|
|||
const convert = require('./convert');
|
||||
const encode = require('./encode');
|
||||
const parse = require('./parse');
|
||||
const resize = require('./resize');
|
||||
const validate = require('./validate');
|
||||
const uploadAvatar = require('./avatar/uploadAvatar');
|
||||
|
||||
module.exports = {
|
||||
...convert,
|
||||
...encode,
|
||||
...parse,
|
||||
...resize,
|
||||
...validate,
|
||||
uploadAvatar,
|
||||
};
|
||||
|
|
27
api/server/services/Files/images/parse.js
Normal file
27
api/server/services/Files/images/parse.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
const URL = require('url').URL;
|
||||
const path = require('path');
|
||||
|
||||
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg)$/i;
|
||||
|
||||
/**
|
||||
* Extracts the image basename from a given URL.
|
||||
*
|
||||
* @param {string} urlString - The URL string from which the image basename is to be extracted.
|
||||
* @returns {string} The basename of the image file from the URL.
|
||||
* Returns an empty string if the URL does not contain a valid image basename.
|
||||
*/
|
||||
function getImageBasename(urlString) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const basename = path.basename(url.pathname);
|
||||
|
||||
return imageExtensionRegex.test(basename) ? basename : '';
|
||||
} catch (error) {
|
||||
// If URL parsing fails, return an empty string
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getImageBasename,
|
||||
};
|
|
@ -1,49 +1,70 @@
|
|||
const { Strategy: DiscordStrategy } = require('passport-discord');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const discordLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.email;
|
||||
const discordId = profile.id;
|
||||
const oldUser = await User.findOne({
|
||||
email,
|
||||
});
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
let avatarURL;
|
||||
let avatarUrl;
|
||||
|
||||
if (profile.avatar) {
|
||||
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png';
|
||||
avatarURL = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
avatarUrl = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`;
|
||||
} else {
|
||||
const defaultAvatarNum = Number(profile.discriminator) % 5;
|
||||
avatarURL = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
avatarUrl = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNum}.png`;
|
||||
}
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = avatarURL;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, discordId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[discordLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if (!useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = newavatarUrl;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, discordId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'discord',
|
||||
discordId,
|
||||
username: profile.username,
|
||||
email,
|
||||
name: profile.global_name,
|
||||
avatar: avatarURL,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = newavatarUrl;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return cb(null, false, {
|
||||
message: 'User not found.',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[discordLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
|
|
|
@ -1,41 +1,62 @@
|
|||
const FacebookStrategy = require('passport-facebook').Strategy;
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const facebookLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
const email = profile.emails[0]?.value;
|
||||
const facebookId = profile.id;
|
||||
const oldUser = await User.findOne({
|
||||
email,
|
||||
});
|
||||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0]?.value;
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = profile.photo;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, facebookId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[facebookLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if (!useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = newavatarUrl;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, facebookId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'facebook',
|
||||
facebookId,
|
||||
username: profile.displayName,
|
||||
email,
|
||||
name: profile.name?.givenName + ' ' + profile.name?.familyName,
|
||||
avatar: profile.photos[0]?.value,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const newavatarUrl = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = newavatarUrl;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return cb(null, false, {
|
||||
message: 'User not found.',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[facebookLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const { Strategy: GitHubStrategy } = require('passport-github2');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const githubLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
|
@ -9,12 +10,36 @@ const githubLogin = async (accessToken, refreshToken, profile, cb) => {
|
|||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0].value;
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = profile.photos[0].value;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, githubId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[githubLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if (!useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = avatarURL;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, githubId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'github',
|
||||
githubId,
|
||||
|
@ -22,17 +47,17 @@ const githubLogin = async (accessToken, refreshToken, profile, cb) => {
|
|||
email,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
name: profile.displayName,
|
||||
avatar: profile.photos[0].value,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = avatarURL;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return cb(null, false, { message: 'User not found.' });
|
||||
} catch (err) {
|
||||
logger.error('[githubLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
||||
const { logger } = require('~/config');
|
||||
const User = require('~/models/User');
|
||||
const { useFirebase, uploadAvatar } = require('~/server/services/Files/images');
|
||||
|
||||
const googleLogin = async (accessToken, refreshToken, profile, cb) => {
|
||||
try {
|
||||
|
@ -9,12 +10,36 @@ const googleLogin = async (accessToken, refreshToken, profile, cb) => {
|
|||
const oldUser = await User.findOne({ email });
|
||||
const ALLOW_SOCIAL_REGISTRATION =
|
||||
process.env.ALLOW_SOCIAL_REGISTRATION?.toLowerCase() === 'true';
|
||||
const avatarUrl = profile.photos[0].value;
|
||||
|
||||
if (oldUser) {
|
||||
oldUser.avatar = profile.photos[0].value;
|
||||
await oldUser.save();
|
||||
await handleExistingUser(oldUser, avatarUrl, useFirebase);
|
||||
return cb(null, oldUser);
|
||||
} else if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
}
|
||||
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createNewUser(profile, googleId, email, avatarUrl, useFirebase);
|
||||
return cb(null, newUser);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[googleLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExistingUser = async (oldUser, avatarUrl, useFirebase) => {
|
||||
if ((!useFirebase && !oldUser.avatar.includes('?manual=true')) || oldUser.avatar === null) {
|
||||
oldUser.avatar = avatarUrl;
|
||||
await oldUser.save();
|
||||
} else if (useFirebase && !oldUser.avatar.includes('?manual=true')) {
|
||||
const userId = oldUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
oldUser.avatar = avatarURL;
|
||||
await oldUser.save();
|
||||
}
|
||||
};
|
||||
|
||||
const createNewUser = async (profile, googleId, email, avatarUrl, useFirebase) => {
|
||||
const newUser = await new User({
|
||||
provider: 'google',
|
||||
googleId,
|
||||
|
@ -22,17 +47,17 @@ const googleLogin = async (accessToken, refreshToken, profile, cb) => {
|
|||
email,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
name: `${profile.name.givenName} ${profile.name.familyName}`,
|
||||
avatar: profile.photos[0].value,
|
||||
avatar: avatarUrl,
|
||||
}).save();
|
||||
|
||||
return cb(null, newUser);
|
||||
if (useFirebase) {
|
||||
const userId = newUser._id;
|
||||
const avatarURL = await uploadAvatar(userId, avatarUrl);
|
||||
newUser.avatar = avatarURL;
|
||||
await newUser.save();
|
||||
}
|
||||
|
||||
return cb(null, false, { message: 'User not found.' });
|
||||
} catch (err) {
|
||||
logger.error('[googleLogin]', err);
|
||||
return cb(err);
|
||||
}
|
||||
return newUser;
|
||||
};
|
||||
|
||||
module.exports = () =>
|
||||
|
|
|
@ -34,7 +34,7 @@ const App = () => {
|
|||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[60] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
</AssistantsProvider>
|
||||
</ToastProvider>
|
||||
|
|
|
@ -27,6 +27,7 @@ export type TShowToast = {
|
|||
severity?: NotificationSeverity;
|
||||
showIcon?: boolean;
|
||||
duration?: number;
|
||||
status?: 'error' | 'success' | 'warning' | 'info';
|
||||
};
|
||||
|
||||
export type TBaseSettingsProps = {
|
||||
|
|
|
@ -6,10 +6,10 @@ import { useChatHelpers, useSSE } from '~/hooks';
|
|||
// import GenerationButtons from './Input/GenerationButtons';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
// import OptionsBar from './Input/OptionsBar';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import Presentation from './Presentation';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import { Spinner } from '~/components';
|
||||
import { buildTree } from '~/utils';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from '~/hooks';
|
||||
import { TooltipProvider, Tooltip } from '~/components/ui';
|
||||
import { Conversations, Pages } from '../Conversations';
|
||||
import { Spinner } from '~/components';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import SearchBar from './SearchBar';
|
||||
import NavToggle from './NavToggle';
|
||||
import NavLinks from './NavLinks';
|
||||
|
|
|
@ -119,7 +119,7 @@ function NavLinks() {
|
|||
<Menu.Item as="div">
|
||||
<NavLink
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-none px-3 py-3 text-sm text-white transition-colors duration-200 hover:bg-gray-700"
|
||||
svg={() => <GearIcon />}
|
||||
svg={() => <GearIcon className="icon-md" />}
|
||||
text={localize('com_nav_settings')}
|
||||
clickHandler={() => setShowSettings(true)}
|
||||
/>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
|
||||
import { GearIcon, DataIcon } from '~/components/svg';
|
||||
import { GearIcon, DataIcon, UserIcon } from '~/components/svg';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { General, Data } from './SettingsTabs';
|
||||
import { General, Data, Account } from './SettingsTabs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
|
@ -39,7 +39,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
>
|
||||
<Tabs.Trigger
|
||||
className={cn(
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-gray-500 radix-state-active:bg-gray-800 radix-state-active:text-white',
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
|
||||
: '',
|
||||
|
@ -51,7 +51,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className={cn(
|
||||
'group flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-gray-500 radix-state-active:bg-gray-800 radix-state-active:text-white',
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
|
||||
: '',
|
||||
|
@ -61,9 +61,22 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<DataIcon />
|
||||
{localize('com_nav_setting_data')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className={cn(
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
|
||||
: '',
|
||||
)}
|
||||
value="account"
|
||||
>
|
||||
<UserIcon />
|
||||
{localize('com_nav_setting_account')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<General />
|
||||
<Data />
|
||||
<Account />
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
18
client/src/components/Nav/SettingsTabs/Account/Account.tsx
Normal file
18
client/src/components/Nav/SettingsTabs/Account/Account.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import Avatar from './Avatar';
|
||||
import React from 'react';
|
||||
|
||||
function Account() {
|
||||
return (
|
||||
<Tabs.Content value="account" role="tabpanel" className="w-full md:min-h-[300px]">
|
||||
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<Avatar />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"></div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Account);
|
145
client/src/components/Nav/SettingsTabs/Account/Avatar.tsx
Normal file
145
client/src/components/Nav/SettingsTabs/Account/Avatar.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { FileImage } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
|
||||
import { useUploadAvatarMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
const sizeLimit = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
function Avatar() {
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [input, setinput] = useState<File | null>(null);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setDialogOpen(false);
|
||||
|
||||
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (input) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(input);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (file && file.size <= sizeLimit) {
|
||||
setinput(file);
|
||||
setDialogOpen(true);
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!input) {
|
||||
console.error('No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input', input, input.name);
|
||||
formData.append('manual', 'true');
|
||||
|
||||
uploadAvatar(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<label
|
||||
htmlFor={'file-upload-avatar'}
|
||||
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-slate-200 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-800 dark:hover:text-green-500"
|
||||
>
|
||||
<FileImage className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
<input
|
||||
id={'file-upload-avatar'}
|
||||
value=""
|
||||
type="file"
|
||||
className={cn('hidden')}
|
||||
accept=".png, .jpg"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>
|
||||
<DialogContent
|
||||
className={cn('shadow-2xl dark:bg-gray-900 dark:text-white md:h-[350px] md:w-[450px] ')}
|
||||
style={{ borderRadius: '12px' }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
{localize('com_ui_preview')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="mb-2 rounded-full"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '150px',
|
||||
width: '150px',
|
||||
height: '150px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
'mt-4 rounded px-4 py-2 text-white hover:bg-green-600 hover:text-gray-200',
|
||||
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
|
||||
)}
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex h-6">
|
||||
<Spinner className="icon-sm m-auto" />
|
||||
</div>
|
||||
) : (
|
||||
localize('com_ui_upload')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from 'librechat-data-provider/react-query';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useOnClickOutside } from '~/hooks';
|
||||
import DangerButton from './DangerButton';
|
||||
import DangerButton from '../DangerButton';
|
||||
|
||||
export const RevokeKeysButton = ({
|
||||
showText = true,
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from '~/hooks';
|
||||
import type { TDangerButtonProps } from '~/common';
|
||||
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||
import DangerButton from './DangerButton';
|
||||
import DangerButton from '../DangerButton';
|
||||
import store from '~/store';
|
||||
import { Dropdown } from '~/components/ui';
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { default as General } from './General';
|
||||
export { ClearChatsButton } from './General';
|
||||
export { default as Data } from './Data';
|
||||
export { RevokeKeysButton } from './Data';
|
||||
export { default as General } from './General/General';
|
||||
export { ClearChatsButton } from './General/General';
|
||||
export { default as Data } from './Data/Data';
|
||||
export { RevokeKeysButton } from './Data/Data';
|
||||
export { default as Account } from './Account/Account';
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function GearIcon() {
|
||||
interface GearIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GearIcon: React.FC<GearIconProps> = ({ className = '' }) => {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
className={className}
|
||||
width="17"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md"
|
||||
>
|
||||
<path
|
||||
d="M11.6439 3C10.9352 3 10.2794 3.37508 9.92002 3.98596L9.49644 4.70605C8.96184 5.61487 7.98938 6.17632 6.93501 6.18489L6.09967 6.19168C5.39096 6.19744 4.73823 6.57783 4.38386 7.19161L4.02776 7.80841C3.67339 8.42219 3.67032 9.17767 4.01969 9.7943L4.43151 10.5212C4.95127 11.4386 4.95127 12.5615 4.43151 13.4788L4.01969 14.2057C3.67032 14.8224 3.67339 15.5778 4.02776 16.1916L4.38386 16.8084C4.73823 17.4222 5.39096 17.8026 6.09966 17.8083L6.93502 17.8151C7.98939 17.8237 8.96185 18.3851 9.49645 19.294L9.92002 20.014C10.2794 20.6249 10.9352 21 11.6439 21H12.3561C13.0648 21 13.7206 20.6249 14.08 20.014L14.5035 19.294C15.0381 18.3851 16.0106 17.8237 17.065 17.8151L17.9004 17.8083C18.6091 17.8026 19.2618 17.4222 19.6162 16.8084L19.9723 16.1916C20.3267 15.5778 20.3298 14.8224 19.9804 14.2057L19.5686 13.4788C19.0488 12.5615 19.0488 11.4386 19.5686 10.5212L19.9804 9.7943C20.3298 9.17767 20.3267 8.42219 19.9723 7.80841L19.6162 7.19161C19.2618 6.57783 18.6091 6.19744 17.9004 6.19168L17.065 6.18489C16.0106 6.17632 15.0382 5.61487 14.5036 4.70605L14.08 3.98596C13.7206 3.37508 13.0648 3 12.3561 3H11.6439Z"
|
||||
|
@ -19,4 +23,6 @@ export default function GearIcon() {
|
|||
<circle cx="12" cy="12" r="2.5" stroke="currentColor" strokeWidth="2"></circle>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default GearIcon;
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function UserIcon() {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
@ -40,3 +40,4 @@ export { default as GeminiIcon } from './GeminiIcon';
|
|||
export { default as GoogleMinimalIcon } from './GoogleMinimalIcon';
|
||||
export { default as AnthropicMinimalIcon } from './AnthropicMinimalIcon';
|
||||
export { default as SendMessageIcon } from './SendMessageIcon';
|
||||
export { default as UserIcon } from './UserIcon';
|
||||
|
|
|
@ -12,6 +12,8 @@ import type {
|
|||
PresetDeleteResponse,
|
||||
LogoutOptions,
|
||||
TPreset,
|
||||
UploadAvatarOptions,
|
||||
AvatarUploadResponse,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
import { dataService, MutationKeys } from 'librechat-data-provider';
|
||||
|
@ -99,3 +101,18 @@ export const useLogoutUserMutation = (
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* Avatar upload */
|
||||
export const useUploadAvatarMutation = (
|
||||
options?: UploadAvatarOptions,
|
||||
): UseMutationResult<
|
||||
AvatarUploadResponse, // response data
|
||||
unknown, // error
|
||||
FormData, // request
|
||||
unknown // context
|
||||
> => {
|
||||
return useMutation([MutationKeys.avatarUpload], {
|
||||
mutationFn: (variables: FormData) => dataService.uploadAvatar(variables),
|
||||
...(options || {}),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
createContext,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { TUser, TLoginResponse, setTokenHeader, TLoginUser } from 'librechat-data-provider';
|
||||
import {
|
||||
useGetUserQuery,
|
||||
|
@ -17,6 +18,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common';
|
||||
import { useLogoutUserMutation } from '~/data-provider';
|
||||
import useTimeout from './useTimeout';
|
||||
import store from '~/store';
|
||||
|
||||
const AuthContext = createContext<TAuthContext | undefined>(undefined);
|
||||
|
||||
|
@ -27,11 +29,13 @@ const AuthContextProvider = ({
|
|||
authConfig?: TAuthConfig;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<TUser | undefined>(undefined);
|
||||
const [user, setUser] = useRecoilState(store.user);
|
||||
const [token, setToken] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const setUserContext = useCallback(
|
||||
(userContext: TUserContext) => {
|
||||
const { token, isAuthenticated, user, redirect } = userContext;
|
||||
|
@ -46,7 +50,7 @@ const AuthContextProvider = ({
|
|||
navigate(redirect, { replace: true });
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
[navigate, setUser],
|
||||
);
|
||||
const doSetError = useTimeout({ callback: (error) => setError(error as string | undefined) });
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ export default function useToast(showDelay = 100) {
|
|||
severity = NotificationSeverity.SUCCESS,
|
||||
showIcon = true,
|
||||
duration = 3000, // default duration for the toast to be visible
|
||||
status,
|
||||
}: TShowToast) => {
|
||||
// Clear existing timeouts
|
||||
if (showTimerRef.current !== null) {
|
||||
|
@ -36,7 +37,12 @@ export default function useToast(showDelay = 100) {
|
|||
|
||||
// Timeout to show the toast
|
||||
showTimerRef.current = window.setTimeout(() => {
|
||||
setToast({ open: true, message, severity, showIcon });
|
||||
setToast({
|
||||
open: true,
|
||||
message,
|
||||
severity: (status as NotificationSeverity) ?? severity,
|
||||
showIcon,
|
||||
});
|
||||
// Hides the toast after the specified duration
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
setToast((prevToast) => ({ ...prevToast, open: false }));
|
||||
|
|
|
@ -33,7 +33,8 @@ export default {
|
|||
com_ui_enter: 'Enter',
|
||||
com_ui_submit: 'Submit',
|
||||
com_ui_upload_success: 'Successfully uploaded file',
|
||||
com_ui_upload_invalid: 'Invalid file for upload',
|
||||
com_ui_upload_error: 'There was an error uploading your file',
|
||||
com_ui_upload_invalid: 'Invalid file for upload. Must be an image not exceeding 2 MB',
|
||||
com_ui_cancel: 'Cancel',
|
||||
com_ui_save: 'Save',
|
||||
com_ui_copy_to_clipboard: 'Copy to clipboard',
|
||||
|
@ -51,6 +52,9 @@ export default {
|
|||
com_ui_delete: 'Delete',
|
||||
com_ui_delete_conversation: 'Delete chat?',
|
||||
com_ui_delete_conversation_confirm: 'This will delete',
|
||||
com_ui_preview: 'Preview',
|
||||
com_ui_upload: 'Upload',
|
||||
com_ui_connect: 'Connect',
|
||||
com_auth_error_login:
|
||||
'Unable to login with the information provided. Please check your credentials and try again.',
|
||||
com_auth_error_login_rl:
|
||||
|
@ -253,6 +257,8 @@ export default {
|
|||
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
|
||||
com_nav_welcome_message: 'How can I help you today?',
|
||||
com_nav_auto_scroll: 'Auto-scroll to Newest on Open',
|
||||
com_nav_profile_picture: 'Profile Picture',
|
||||
com_nav_change_picture: 'Change picture',
|
||||
com_nav_plugin_store: 'Plugin store',
|
||||
com_nav_plugin_search: 'Search plugins',
|
||||
com_nav_plugin_auth_error:
|
||||
|
@ -286,6 +292,7 @@ export default {
|
|||
com_nav_search_placeholder: 'Search messages',
|
||||
com_nav_setting_general: 'General',
|
||||
com_nav_setting_data: 'Data controls',
|
||||
com_nav_setting_account: 'Account',
|
||||
com_nav_language: 'Language',
|
||||
com_nav_lang_auto: 'Auto detect',
|
||||
com_nav_lang_english: 'English',
|
||||
|
|
|
@ -53,6 +53,9 @@ export default {
|
|||
com_ui_delete: 'Elimina',
|
||||
com_ui_delete_conversation: 'Eliminare la chat?',
|
||||
com_ui_delete_conversation_confirm: 'Questo eliminerà',
|
||||
com_ui_preview: 'Anteprima',
|
||||
com_ui_upload: 'Carica',
|
||||
com_ui_connect: 'Connetti',
|
||||
com_auth_error_login:
|
||||
'Impossibile accedere con le informazioni fornite. Per favore controlla le tue credenziali e riprova.',
|
||||
com_auth_error_login_rl:
|
||||
|
@ -263,7 +266,9 @@ export default {
|
|||
'Assicurati di fare clic su "Crea e continua" per dare almeno il ruolo "Vertex AI User". Infine, crea una chiave JSON da importare qui.',
|
||||
com_nav_welcome_message: 'Come posso aiutarti oggi?',
|
||||
com_nav_auto_scroll: 'Scorri automaticamente al Più recente all\'apertura',
|
||||
com_nav_plugin_store: 'Negozio plugin',
|
||||
com_nav_profile_picture: 'Immagine del profilo',
|
||||
com_nav_change_picture: 'Cambia immagine',
|
||||
com_nav_plugin_store: 'Negozio dei plugin',
|
||||
com_nav_plugin_search: 'Cerca plugin',
|
||||
com_nav_plugin_auth_error:
|
||||
'Si è verificato un errore durante il tentativo di autenticare questo plugin. Per favore riprova.',
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { atom } from 'recoil';
|
||||
import { TPlugin } from 'librechat-data-provider';
|
||||
import type { TUser, TPlugin } from 'librechat-data-provider';
|
||||
|
||||
const user = atom({
|
||||
const user = atom<TUser | undefined>({
|
||||
key: 'user',
|
||||
default: null,
|
||||
default: undefined,
|
||||
});
|
||||
|
||||
const availableTools = atom<TPlugin[]>({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: 😈 Bing Jailbreak
|
||||
description: Quick overview of the Bing jailbreak and Sydney's system message
|
||||
weight: -3
|
||||
weight: -2
|
||||
---
|
||||
|
||||
# Bing Jailbreak
|
||||
|
|
74
docs/features/firebase.md
Normal file
74
docs/features/firebase.md
Normal file
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
title: 🔥 Firebase CDN Setup
|
||||
description: This document provides instructions for setting up Firebase CDN for LibreChat
|
||||
weight: -6
|
||||
---
|
||||
|
||||
# Firebase CDN Setup
|
||||
|
||||
## Steps to Set Up Firebase
|
||||
|
||||
1. Open the [Firebase website](https://firebase.google.com/).
|
||||
2. Click on "Get started."
|
||||
3. Sign in with your Google account.
|
||||
|
||||
### Create a New Project
|
||||
|
||||
- Name your project (you can use the same project as Google OAuth).
|
||||
|
||||

|
||||
|
||||
- Optionally, you can disable Google Analytics.
|
||||
|
||||

|
||||
|
||||
- Wait for 20/30 seconds for the project to be ready, then click on "Continue."
|
||||
|
||||

|
||||
|
||||
- Click on "All Products."
|
||||
|
||||

|
||||
|
||||
- Select "Storage."
|
||||
|
||||

|
||||
|
||||
- Click on "Get Started."
|
||||
|
||||

|
||||
|
||||
- Click on "Next."
|
||||
|
||||

|
||||
|
||||
- Select your "Cloud Storage location."
|
||||
|
||||

|
||||
|
||||
- Return to the Project Overview.
|
||||
|
||||

|
||||
|
||||
- Click on "+ Add app" under your project name, then click on "Web."
|
||||
|
||||

|
||||
|
||||
- Register the app.
|
||||
|
||||

|
||||
|
||||
- Save all this information in a text file.
|
||||
|
||||

|
||||
|
||||
- Fill all the `firebaseConfig` variables in the `.env` file.
|
||||
|
||||
```bash
|
||||
FIREBASE_API_KEY=api_key #apiKey
|
||||
FIREBASE_AUTH_DOMAIN=auth_domain #authDomain
|
||||
FIREBASE_PROJECT_ID=project_id #projectId
|
||||
FIREBASE_STORAGE_BUCKET=storage_bucket #storageBucket
|
||||
FIREBASE_MESSAGING_SENDER_ID=messaging_sender_id #messagingSenderId
|
||||
FIREBASE_APP_ID=1:your_app_id #appId
|
||||
```
|
|
@ -22,6 +22,7 @@ weight: 2
|
|||
|
||||
* 🔨 [Automated Moderation](./mod_system.md)
|
||||
* 🪙 [Token Usage](./token_usage.md)
|
||||
* 🔥 [Firebase CDN](./firebase.md)
|
||||
* 🍃 [Manage Your Database](./manage_your_database.md)
|
||||
* 🪵 [Logging System](./logging_system.md)
|
||||
* 📦 [PandoraNext](./pandoranext.md)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: 🪵 Logging System
|
||||
weight: -4
|
||||
description: This doc explains how to use the logging feature of LibreChat, which saves error and debug logs in the `/api/logs` folder. You can use these logs to troubleshoot issues, monitor your server, and report bugs. You can also disable debug logs if you want to save space.
|
||||
weight: -5
|
||||
---
|
||||
|
||||
### General
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: 🍃 Manage Your Database
|
||||
description: How to install and configure Mongo Express to securely access and manage your MongoDB database in Docker.
|
||||
weight: -6
|
||||
weight: -5
|
||||
---
|
||||
|
||||
<img src="https://github.com/danny-avila/LibreChat/assets/32828263/4572dd35-8489-4cb1-a968-4fb5a871d6e5" height="50">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: 📦 PandoraNext
|
||||
description: How to deploy PandoraNext to enable the `CHATGPT_REVERSE_PROXY` for use with LibreChat.
|
||||
weight: -4
|
||||
weight: -3
|
||||
---
|
||||
|
||||
# PandoraNext Deployment Guide
|
||||
|
|
902
package-lock.json
generated
902
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -69,3 +69,5 @@ export const assistants = (id?: string) => `/api/assistants${id ? `/${id}` : ''}
|
|||
export const files = () => '/api/files';
|
||||
|
||||
export const images = () => `${files()}/images`;
|
||||
|
||||
export const avatar = () => `${images()}/avatar`;
|
||||
|
|
|
@ -197,6 +197,10 @@ export const uploadImage = (data: FormData): Promise<f.FileUploadResponse> => {
|
|||
return request.postMultiPart(endpoints.images(), data);
|
||||
};
|
||||
|
||||
export const uploadAvatar = (data: FormData): Promise<f.AvatarUploadResponse> => {
|
||||
return request.postMultiPart(endpoints.avatar(), data);
|
||||
};
|
||||
|
||||
export const deleteFiles = async (files: f.BatchFile[]): Promise<f.DeleteFilesResponse> =>
|
||||
request.deleteWithOptions(endpoints.files(), {
|
||||
data: { files },
|
||||
|
|
|
@ -24,4 +24,5 @@ export enum MutationKeys {
|
|||
updatePreset = 'updatePreset',
|
||||
deletePreset = 'deletePreset',
|
||||
logoutUser = 'logoutUser',
|
||||
avatarUpload = 'avatarUpload',
|
||||
}
|
||||
|
|
|
@ -10,6 +10,10 @@ export type FileUploadResponse = {
|
|||
width: number;
|
||||
};
|
||||
|
||||
export type AvatarUploadResponse = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type FileUploadBody = {
|
||||
formData: FormData;
|
||||
file_id: string;
|
||||
|
@ -21,6 +25,12 @@ export type UploadMutationOptions = {
|
|||
onError?: (error: unknown, variables: FileUploadBody, context?: unknown) => void;
|
||||
};
|
||||
|
||||
export type UploadAvatarOptions = {
|
||||
onSuccess?: (data: AvatarUploadResponse, variables: FormData, context?: unknown) => void;
|
||||
onMutate?: (variables: FormData) => void | Promise<unknown>;
|
||||
onError?: (error: unknown, variables: FormData, context?: unknown) => void;
|
||||
};
|
||||
|
||||
export type DeleteFilesResponse = {
|
||||
message: string;
|
||||
result: Record<string, unknown>;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue