Compare commits
10 Commits
feat/compo
...
feat/E2EE
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e59bc55c | ||
|
|
94f0d1cb41 | ||
|
|
d37cc1cf4d | ||
|
|
7346d20224 | ||
|
|
0cc0e5d287 | ||
|
|
d01674a4c6 | ||
|
|
d4621c3ea8 | ||
|
|
94d32906f1 | ||
|
|
606fea044a | ||
|
|
18d019d8b3 |
@@ -1,4 +1,3 @@
|
|||||||
const crypto = require('crypto');
|
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const {
|
const {
|
||||||
supportsBalanceCheck,
|
supportsBalanceCheck,
|
||||||
@@ -9,7 +8,7 @@ const {
|
|||||||
ErrorTypes,
|
ErrorTypes,
|
||||||
Constants,
|
Constants,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
|
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo, getUserById } = require('~/models');
|
||||||
const { addSpaceIfNeeded, isEnabled } = require('~/server/utils');
|
const { addSpaceIfNeeded, isEnabled } = require('~/server/utils');
|
||||||
const { truncateToolCallOutputs } = require('./prompts');
|
const { truncateToolCallOutputs } = require('./prompts');
|
||||||
const checkBalance = require('~/models/checkBalance');
|
const checkBalance = require('~/models/checkBalance');
|
||||||
@@ -17,6 +16,48 @@ const { getFiles } = require('~/models/File');
|
|||||||
const TextStream = require('./TextStream');
|
const TextStream = require('./TextStream');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
let crypto;
|
||||||
|
try {
|
||||||
|
crypto = require('crypto');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[AskController] crypto support is disabled!', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to encrypt plaintext using AES-256-GCM and then RSA-encrypt the AES key.
|
||||||
|
* @param {string} plainText - The plaintext to encrypt.
|
||||||
|
* @param {string} pemPublicKey - The RSA public key in PEM format.
|
||||||
|
* @returns {Object} An object containing the ciphertext, iv, authTag, and encryptedKey.
|
||||||
|
*/
|
||||||
|
function encryptText(plainText, pemPublicKey) {
|
||||||
|
// Generate a random 256-bit AES key and a 12-byte IV.
|
||||||
|
const aesKey = crypto.randomBytes(32);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
|
||||||
|
// Encrypt the plaintext using AES-256-GCM.
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
||||||
|
let ciphertext = cipher.update(plainText, 'utf8', 'base64');
|
||||||
|
ciphertext += cipher.final('base64');
|
||||||
|
const authTag = cipher.getAuthTag().toString('base64');
|
||||||
|
|
||||||
|
// Encrypt the AES key using the user's RSA public key.
|
||||||
|
const encryptedKey = crypto.publicEncrypt(
|
||||||
|
{
|
||||||
|
key: pemPublicKey,
|
||||||
|
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
oaepHash: 'sha256',
|
||||||
|
},
|
||||||
|
aesKey,
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
authTag,
|
||||||
|
encryptedKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class BaseClient {
|
class BaseClient {
|
||||||
constructor(apiKey, options = {}) {
|
constructor(apiKey, options = {}) {
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
@@ -849,18 +890,64 @@ class BaseClient {
|
|||||||
* @param {string | null} user
|
* @param {string | null} user
|
||||||
*/
|
*/
|
||||||
async saveMessageToDatabase(message, endpointOptions, user = null) {
|
async saveMessageToDatabase(message, endpointOptions, user = null) {
|
||||||
if (this.user && user !== this.user) {
|
// Normalize the user information:
|
||||||
|
// If "user" is an object, use it; otherwise, if a string is passed use req.user (if available)
|
||||||
|
const currentUser =
|
||||||
|
user && typeof user === 'object'
|
||||||
|
? user
|
||||||
|
: (this.options.req && this.options.req.user
|
||||||
|
? this.options.req.user
|
||||||
|
: { id: user });
|
||||||
|
const currentUserId = currentUser.id || currentUser;
|
||||||
|
|
||||||
|
// Check if the client’s stored user matches the current user.
|
||||||
|
// (this.user might have been set earlier in setMessageOptions)
|
||||||
|
const storedUserId =
|
||||||
|
this.user && typeof this.user === 'object' ? this.user.id : this.user;
|
||||||
|
if (storedUserId && currentUserId && storedUserId !== currentUserId) {
|
||||||
throw new Error('User mismatch.');
|
throw new Error('User mismatch.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.log('User ID:', currentUserId);
|
||||||
|
|
||||||
|
const dbUser = await getUserById(currentUserId, 'encryptionPublicKey');
|
||||||
|
|
||||||
|
// --- NEW ENCRYPTION BLOCK: Encrypt AI response if encryptionPublicKey exists ---
|
||||||
|
if (dbUser.encryptionPublicKey && message && message.text) {
|
||||||
|
try {
|
||||||
|
// Rebuild the PEM format if necessary.
|
||||||
|
const pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${dbUser.encryptionPublicKey
|
||||||
|
.match(/.{1,64}/g)
|
||||||
|
.join('\n')}\n-----END PUBLIC KEY-----`;
|
||||||
|
const { ciphertext, iv, authTag, encryptedKey } = encryptText(
|
||||||
|
message.text,
|
||||||
|
pemPublicKey,
|
||||||
|
);
|
||||||
|
message.text = ciphertext;
|
||||||
|
message.iv = iv;
|
||||||
|
message.authTag = authTag;
|
||||||
|
message.encryptedKey = encryptedKey;
|
||||||
|
logger.debug('[BaseClient.saveMessageToDatabase] Encrypted message text');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[BaseClient.saveMessageToDatabase] Error encrypting message text', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- End Encryption Block ---
|
||||||
|
|
||||||
|
// Build update parameters including encryption fields.
|
||||||
|
const updateParams = {
|
||||||
|
...message,
|
||||||
|
endpoint: this.options.endpoint,
|
||||||
|
unfinished: false,
|
||||||
|
user: currentUserId, // store the user id (ensured to be a string)
|
||||||
|
iv: message.iv ?? null,
|
||||||
|
authTag: message.authTag ?? null,
|
||||||
|
encryptedKey: message.encryptedKey ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
const savedMessage = await saveMessage(
|
const savedMessage = await saveMessage(
|
||||||
this.options.req,
|
this.options.req,
|
||||||
{
|
updateParams,
|
||||||
...message,
|
|
||||||
endpoint: this.options.endpoint,
|
|
||||||
unfinished: false,
|
|
||||||
user,
|
|
||||||
},
|
|
||||||
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
|
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1149,4 +1236,4 @@ class BaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = BaseClient;
|
module.exports = BaseClient;
|
||||||
@@ -2,6 +2,7 @@ const { z } = require('zod');
|
|||||||
const Message = require('./schema/messageSchema');
|
const Message = require('./schema/messageSchema');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
// Validate conversation ID as a UUID (if your conversation IDs follow UUID format)
|
||||||
const idSchema = z.string().uuid();
|
const idSchema = z.string().uuid();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,8 +29,11 @@ const idSchema = z.string().uuid();
|
|||||||
* @param {string} [params.plugin] - Plugin associated with the message.
|
* @param {string} [params.plugin] - Plugin associated with the message.
|
||||||
* @param {string[]} [params.plugins] - An array of plugins associated with the message.
|
* @param {string[]} [params.plugins] - An array of plugins associated with the message.
|
||||||
* @param {string} [params.model] - The model used to generate the message.
|
* @param {string} [params.model] - The model used to generate the message.
|
||||||
* @param {Object} [metadata] - Additional metadata for this operation
|
* @param {string} [params.iv] - (Optional) Base64-encoded initialization vector for encryption.
|
||||||
* @param {string} [metadata.context] - The context of the operation
|
* @param {string} [params.authTag] - (Optional) Base64-encoded authentication tag from AES-GCM.
|
||||||
|
* @param {string} [params.encryptedKey] - (Optional) Base64-encoded AES key encrypted with RSA.
|
||||||
|
* @param {Object} [metadata] - Additional metadata for this operation.
|
||||||
|
* @param {string} [metadata.context] - The context of the operation.
|
||||||
* @returns {Promise<TMessage>} The updated or newly inserted message document.
|
* @returns {Promise<TMessage>} The updated or newly inserted message document.
|
||||||
* @throws {Error} If there is an error in saving the message.
|
* @throws {Error} If there is an error in saving the message.
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +55,9 @@ async function saveMessage(req, params, metadata) {
|
|||||||
...params,
|
...params,
|
||||||
user: req.user.id,
|
user: req.user.id,
|
||||||
messageId: params.newMessageId || params.messageId,
|
messageId: params.newMessageId || params.messageId,
|
||||||
|
iv: params.iv ?? null,
|
||||||
|
authTag: params.authTag ?? null,
|
||||||
|
encryptedKey: params.encryptedKey ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req?.body?.isTemporary) {
|
if (req?.body?.isTemporary) {
|
||||||
@@ -90,7 +97,12 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
|
|||||||
const bulkOps = messages.map((message) => ({
|
const bulkOps = messages.map((message) => ({
|
||||||
updateOne: {
|
updateOne: {
|
||||||
filter: { messageId: message.messageId },
|
filter: { messageId: message.messageId },
|
||||||
update: message,
|
update: {
|
||||||
|
...message,
|
||||||
|
iv: message.iv ?? null,
|
||||||
|
authTag: message.authTag ?? null,
|
||||||
|
encryptedKey: message.encryptedKey ?? null,
|
||||||
|
},
|
||||||
timestamps: !overrideTimestamp,
|
timestamps: !overrideTimestamp,
|
||||||
upsert: true,
|
upsert: true,
|
||||||
},
|
},
|
||||||
@@ -119,14 +131,7 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
|
|||||||
* @returns {Promise<Object>} The updated or newly inserted message document.
|
* @returns {Promise<Object>} The updated or newly inserted message document.
|
||||||
* @throws {Error} If there is an error in saving the message.
|
* @throws {Error} If there is an error in saving the message.
|
||||||
*/
|
*/
|
||||||
async function recordMessage({
|
async function recordMessage({ user, endpoint, messageId, conversationId, parentMessageId, ...rest }) {
|
||||||
user,
|
|
||||||
endpoint,
|
|
||||||
messageId,
|
|
||||||
conversationId,
|
|
||||||
parentMessageId,
|
|
||||||
...rest
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
// No parsing of convoId as may use threadId
|
// No parsing of convoId as may use threadId
|
||||||
const message = {
|
const message = {
|
||||||
@@ -136,6 +141,9 @@ async function recordMessage({
|
|||||||
conversationId,
|
conversationId,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
...rest,
|
...rest,
|
||||||
|
iv: rest.iv ?? null,
|
||||||
|
authTag: rest.authTag ?? null,
|
||||||
|
encryptedKey: rest.encryptedKey ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return await Message.findOneAndUpdate({ user, messageId }, message, {
|
return await Message.findOneAndUpdate({ user, messageId }, message, {
|
||||||
@@ -190,12 +198,15 @@ async function updateMessageText(req, { messageId, text }) {
|
|||||||
async function updateMessage(req, message, metadata) {
|
async function updateMessage(req, message, metadata) {
|
||||||
try {
|
try {
|
||||||
const { messageId, ...update } = message;
|
const { messageId, ...update } = message;
|
||||||
|
// Ensure encryption fields are explicitly updated (if provided)
|
||||||
|
update.iv = update.iv ?? null;
|
||||||
|
update.authTag = update.authTag ?? null;
|
||||||
|
update.encryptedKey = update.encryptedKey ?? null;
|
||||||
|
|
||||||
const updatedMessage = await Message.findOneAndUpdate(
|
const updatedMessage = await Message.findOneAndUpdate(
|
||||||
{ messageId, user: req.user.id },
|
{ messageId, user: req.user.id },
|
||||||
update,
|
update,
|
||||||
{
|
{ new: true },
|
||||||
new: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!updatedMessage) {
|
if (!updatedMessage) {
|
||||||
@@ -225,11 +236,11 @@ async function updateMessage(req, message, metadata) {
|
|||||||
*
|
*
|
||||||
* @async
|
* @async
|
||||||
* @function deleteMessagesSince
|
* @function deleteMessagesSince
|
||||||
* @param {Object} params - The parameters object.
|
|
||||||
* @param {Object} req - The request object.
|
* @param {Object} req - The request object.
|
||||||
|
* @param {Object} params - The parameters object.
|
||||||
* @param {string} params.messageId - The unique identifier for the message.
|
* @param {string} params.messageId - The unique identifier for the message.
|
||||||
* @param {string} params.conversationId - The identifier of the conversation.
|
* @param {string} params.conversationId - The identifier of the conversation.
|
||||||
* @returns {Promise<Number>} The number of deleted messages.
|
* @returns {Promise<number>} The number of deleted messages.
|
||||||
* @throws {Error} If there is an error in deleting messages.
|
* @throws {Error} If there is an error in deleting messages.
|
||||||
*/
|
*/
|
||||||
async function deleteMessagesSince(req, { messageId, conversationId }) {
|
async function deleteMessagesSince(req, { messageId, conversationId }) {
|
||||||
@@ -263,7 +274,6 @@ async function getMessages(filter, select) {
|
|||||||
if (select) {
|
if (select) {
|
||||||
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
|
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Message.find(filter).sort({ createdAt: 1 }).lean();
|
return await Message.find(filter).sort({ createdAt: 1 }).lean();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error getting messages:', err);
|
logger.error('Error getting messages:', err);
|
||||||
@@ -281,10 +291,7 @@ async function getMessages(filter, select) {
|
|||||||
*/
|
*/
|
||||||
async function getMessage({ user, messageId }) {
|
async function getMessage({ user, messageId }) {
|
||||||
try {
|
try {
|
||||||
return await Message.findOne({
|
return await Message.findOne({ user, messageId }).lean();
|
||||||
user,
|
|
||||||
messageId,
|
|
||||||
}).lean();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error getting message:', err);
|
logger.error('Error getting message:', err);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ const messageSchema = mongoose.Schema(
|
|||||||
expiredAt: {
|
expiredAt: {
|
||||||
type: Date,
|
type: Date,
|
||||||
},
|
},
|
||||||
|
iv: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
authTag: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
encryptedKey: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true },
|
{ timestamps: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const { SystemRoles } = require('librechat-data-provider');
|
|||||||
* @property {Array} [plugins=[]] - List of plugins used by the user
|
* @property {Array} [plugins=[]] - List of plugins used by the user
|
||||||
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
|
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
|
||||||
* @property {Date} [expiresAt] - Optional expiration date of the file
|
* @property {Date} [expiresAt] - Optional expiration date of the file
|
||||||
|
* @property {string} [encryptionPublicKey] - The user's encryption public key
|
||||||
|
* @property {string} [encryptedPrivateKey] - The user's encrypted private key
|
||||||
|
* @property {string} [encryptionSalt] - The salt used for key derivation (e.g., PBKDF2)
|
||||||
|
* @property {string} [encryptionIV] - The IV used for encrypting the private key
|
||||||
* @property {Date} [createdAt] - Date when the user was created (added by timestamps)
|
* @property {Date} [createdAt] - Date when the user was created (added by timestamps)
|
||||||
* @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps)
|
* @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps)
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +147,22 @@ const userSchema = mongoose.Schema(
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
encryptionPublicKey: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
encryptedPrivateKey: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
encryptionSalt: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
encryptionIV: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{ timestamps: true },
|
{ timestamps: true },
|
||||||
|
|||||||
@@ -1,9 +1,59 @@
|
|||||||
const { getResponseSender, Constants } = require('librechat-data-provider');
|
const { getResponseSender, Constants } = require('librechat-data-provider');
|
||||||
const { createAbortController, handleAbortError } = require('~/server/middleware');
|
const { createAbortController, handleAbortError } = require('~/server/middleware');
|
||||||
const { sendMessage, createOnProgress } = require('~/server/utils');
|
const { sendMessage, createOnProgress } = require('~/server/utils');
|
||||||
const { saveMessage } = require('~/models');
|
const { saveMessage, getUserById } = require('~/models');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
|
let crypto;
|
||||||
|
try {
|
||||||
|
crypto = require('crypto');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[AskController] crypto support is disabled!', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to encrypt plaintext using AES-256-GCM and then RSA-encrypt the AES key.
|
||||||
|
* @param {string} plainText - The plaintext to encrypt.
|
||||||
|
* @param {string} pemPublicKey - The RSA public key in PEM format.
|
||||||
|
* @returns {Object} An object containing the ciphertext, iv, authTag, and encryptedKey.
|
||||||
|
*/
|
||||||
|
function encryptText(plainText, pemPublicKey) {
|
||||||
|
// Generate a random 256-bit AES key and a 12-byte IV.
|
||||||
|
const aesKey = crypto.randomBytes(32);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
|
||||||
|
// Encrypt the plaintext using AES-256-GCM.
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
||||||
|
let ciphertext = cipher.update(plainText, 'utf8', 'base64');
|
||||||
|
ciphertext += cipher.final('base64');
|
||||||
|
const authTag = cipher.getAuthTag().toString('base64');
|
||||||
|
|
||||||
|
// Encrypt the AES key using the user's RSA public key.
|
||||||
|
const encryptedKey = crypto.publicEncrypt(
|
||||||
|
{
|
||||||
|
key: pemPublicKey,
|
||||||
|
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
oaepHash: 'sha256',
|
||||||
|
},
|
||||||
|
aesKey,
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
authTag,
|
||||||
|
encryptedKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AskController
|
||||||
|
* - Initializes the client.
|
||||||
|
* - Obtains the response from the language model.
|
||||||
|
* - Retrieves the full user record (to get encryption parameters).
|
||||||
|
* - If the user has encryption enabled (i.e. encryptionPublicKey is provided),
|
||||||
|
* encrypts both the request (userMessage) and the response before saving.
|
||||||
|
*/
|
||||||
const AskController = async (req, res, next, initializeClient, addTitle) => {
|
const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||||
let {
|
let {
|
||||||
text,
|
text,
|
||||||
@@ -32,7 +82,22 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
modelDisplayLabel,
|
modelDisplayLabel,
|
||||||
});
|
});
|
||||||
const newConvo = !conversationId;
|
const newConvo = !conversationId;
|
||||||
const user = req.user.id;
|
const userId = req.user.id; // User ID from authentication
|
||||||
|
|
||||||
|
// Retrieve full user record from DB (including encryption parameters)
|
||||||
|
const dbUser = await getUserById(userId, 'encryptionPublicKey encryptedPrivateKey encryptionSalt encryptionIV');
|
||||||
|
|
||||||
|
// Build clientOptions including the encryptionPublicKey (if available)
|
||||||
|
const clientOptions = {
|
||||||
|
encryptionPublicKey: dbUser?.encryptionPublicKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rebuild PEM format if encryptionPublicKey is available
|
||||||
|
let pemPublicKey = null;
|
||||||
|
if (clientOptions.encryptionPublicKey && clientOptions.encryptionPublicKey.trim() !== '') {
|
||||||
|
const pubKeyBase64 = clientOptions.encryptionPublicKey;
|
||||||
|
pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${pubKeyBase64.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----`;
|
||||||
|
}
|
||||||
|
|
||||||
const getReqData = (data = {}) => {
|
const getReqData = (data = {}) => {
|
||||||
for (let key in data) {
|
for (let key in data) {
|
||||||
@@ -52,11 +117,10 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let getText;
|
let getText;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { client } = await initializeClient({ req, res, endpointOption });
|
// Pass clientOptions (which includes encryptionPublicKey) along with other parameters to initializeClient
|
||||||
|
const { client } = await initializeClient({ req, res, endpointOption, ...clientOptions });
|
||||||
const { onProgress: progressCallback, getPartialText } = createOnProgress();
|
const { onProgress: progressCallback, getPartialText } = createOnProgress();
|
||||||
|
|
||||||
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
|
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
|
||||||
|
|
||||||
const getAbortData = () => ({
|
const getAbortData = () => ({
|
||||||
@@ -74,20 +138,14 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
logger.debug('[AskController] Request closed');
|
logger.debug('[AskController] Request closed');
|
||||||
if (!abortController) {
|
if (!abortController) { return; }
|
||||||
return;
|
if (abortController.signal.aborted || abortController.requestCompleted) { return; }
|
||||||
} else if (abortController.signal.aborted) {
|
|
||||||
return;
|
|
||||||
} else if (abortController.requestCompleted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
logger.debug('[AskController] Request aborted on close');
|
logger.debug('[AskController] Request aborted on close');
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageOptions = {
|
const messageOptions = {
|
||||||
user,
|
user: userId,
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
conversationId,
|
conversationId,
|
||||||
overrideParentMessageId,
|
overrideParentMessageId,
|
||||||
@@ -95,16 +153,14 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
onStart,
|
onStart,
|
||||||
abortController,
|
abortController,
|
||||||
progressCallback,
|
progressCallback,
|
||||||
progressOptions: {
|
progressOptions: { res },
|
||||||
res,
|
|
||||||
// parentMessageId: overrideParentMessageId || userMessageId,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {TMessage} */
|
// Get the response from the language model client.
|
||||||
let response = await client.sendMessage(text, messageOptions);
|
let response = await client.sendMessage(text, messageOptions);
|
||||||
response.endpoint = endpointOption.endpoint;
|
response.endpoint = endpointOption.endpoint;
|
||||||
|
|
||||||
|
// Ensure the conversation has a title.
|
||||||
const { conversation = {} } = await client.responsePromise;
|
const { conversation = {} } = await client.responsePromise;
|
||||||
conversation.title =
|
conversation.title =
|
||||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||||
@@ -115,6 +171,35 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
delete userMessage.image_urls;
|
delete userMessage.image_urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Encrypt the user message if encryption is enabled ---
|
||||||
|
if (pemPublicKey && userMessage && userMessage.text) {
|
||||||
|
try {
|
||||||
|
const { ciphertext, iv, authTag, encryptedKey } = encryptText(userMessage.text, pemPublicKey);
|
||||||
|
userMessage.text = ciphertext;
|
||||||
|
userMessage.iv = iv;
|
||||||
|
userMessage.authTag = authTag;
|
||||||
|
userMessage.encryptedKey = encryptedKey;
|
||||||
|
logger.debug('[AskController] User message encrypted.');
|
||||||
|
} catch (encError) {
|
||||||
|
logger.error('[AskController] Error encrypting user message:', encError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Encrypt the AI response if encryption is enabled ---
|
||||||
|
if (pemPublicKey && response.text) {
|
||||||
|
try {
|
||||||
|
const { ciphertext, iv, authTag, encryptedKey } = encryptText(response.text, pemPublicKey);
|
||||||
|
response.text = ciphertext;
|
||||||
|
response.iv = iv;
|
||||||
|
response.authTag = authTag;
|
||||||
|
response.encryptedKey = encryptedKey;
|
||||||
|
logger.debug('[AskController] Response message encrypted.');
|
||||||
|
} catch (encError) {
|
||||||
|
logger.error('[AskController] Error encrypting response message:', encError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- End Encryption Branch ---
|
||||||
|
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
sendMessage(res, {
|
sendMessage(res, {
|
||||||
final: true,
|
final: true,
|
||||||
@@ -128,15 +213,15 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
if (!client.savedMessageIds.has(response.messageId)) {
|
if (!client.savedMessageIds.has(response.messageId)) {
|
||||||
await saveMessage(
|
await saveMessage(
|
||||||
req,
|
req,
|
||||||
{ ...response, user },
|
{ ...response, user: userId },
|
||||||
{ context: 'api/server/controllers/AskController.js - response end' },
|
{ context: 'AskController - response end' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client.skipSaveUserMessage) {
|
if (!client.skipSaveUserMessage) {
|
||||||
await saveMessage(req, userMessage, {
|
await saveMessage(req, userMessage, {
|
||||||
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
|
context: 'AskController - save user message',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,9 +241,9 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||||||
messageId: responseMessageId,
|
messageId: responseMessageId,
|
||||||
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
logger.error('[AskController] Error in `handleAbortError`', err);
|
logger.error('[AskController] Error in handleAbortError', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = AskController;
|
module.exports = AskController;
|
||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
deleteMessages,
|
deleteMessages,
|
||||||
deleteUserById,
|
deleteUserById,
|
||||||
deleteAllUserSessions,
|
deleteAllUserSessions,
|
||||||
|
updateUser,
|
||||||
} = require('~/models');
|
} = require('~/models');
|
||||||
const User = require('~/models/User');
|
const User = require('~/models/User');
|
||||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||||
@@ -164,6 +165,37 @@ const resendVerificationController = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateUserEncryptionController = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body;
|
||||||
|
|
||||||
|
// Allow disabling encryption by passing null for all fields.
|
||||||
|
const allNull = encryptionPublicKey === null && encryptedPrivateKey === null && encryptionSalt === null && encryptionIV === null;
|
||||||
|
const allPresent = encryptionPublicKey && encryptedPrivateKey && encryptionSalt && encryptionIV;
|
||||||
|
|
||||||
|
if (!allNull && !allPresent) {
|
||||||
|
return res.status(400).json({ message: 'Missing encryption parameters.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user record with the provided encryption parameters (or null to disable)
|
||||||
|
const updatedUser = await updateUser(req.user.id, {
|
||||||
|
encryptionPublicKey: encryptionPublicKey || null,
|
||||||
|
encryptedPrivateKey: encryptedPrivateKey || null,
|
||||||
|
encryptionSalt: encryptionSalt || null,
|
||||||
|
encryptionIV: encryptionIV || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
return res.status(404).json({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[updateUserEncryptionController]', error);
|
||||||
|
res.status(500).json({ message: 'Something went wrong updating encryption keys.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getUserController,
|
getUserController,
|
||||||
getTermsStatusController,
|
getTermsStatusController,
|
||||||
@@ -172,4 +204,5 @@ module.exports = {
|
|||||||
verifyEmailController,
|
verifyEmailController,
|
||||||
updateUserPluginsController,
|
updateUserPluginsController,
|
||||||
resendVerificationController,
|
resendVerificationController,
|
||||||
|
updateUserEncryptionController,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ const {
|
|||||||
resendVerificationController,
|
resendVerificationController,
|
||||||
getTermsStatusController,
|
getTermsStatusController,
|
||||||
acceptTermsController,
|
acceptTermsController,
|
||||||
|
updateUserEncryptionController,
|
||||||
} = require('~/server/controllers/UserController');
|
} = require('~/server/controllers/UserController');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', requireJwtAuth, getUserController);
|
router.get('/', requireJwtAuth, getUserController);
|
||||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||||
|
router.put('/encryption', requireJwtAuth, updateUserEncryptionController);
|
||||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||||
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
|
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, Suspense, useMemo } from 'react';
|
import React, { memo, Suspense, useMemo, useEffect, useState } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||||
@@ -13,6 +13,77 @@ import Container from './Container';
|
|||||||
import Markdown from './Markdown';
|
import Markdown from './Markdown';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
import { useAuthContext } from '~/hooks/AuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Converts a base64 string to an ArrayBuffer.
|
||||||
|
*/
|
||||||
|
const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
||||||
|
const binaryStr = window.atob(base64);
|
||||||
|
const len = binaryStr.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Decrypts an encrypted chat message using the provided RSA private key.
|
||||||
|
* Expects the message object to have: text (ciphertext), iv, authTag, and encryptedKey.
|
||||||
|
*/
|
||||||
|
async function decryptChatMessage(
|
||||||
|
msg: { text: string; iv: string; authTag: string; encryptedKey: string },
|
||||||
|
privateKey: CryptoKey
|
||||||
|
): Promise<string> {
|
||||||
|
// Convert base64 values to ArrayBuffers.
|
||||||
|
const ciphertextBuffer = base64ToArrayBuffer(msg.text);
|
||||||
|
const ivBuffer = new Uint8Array(base64ToArrayBuffer(msg.iv));
|
||||||
|
const authTagBuffer = new Uint8Array(base64ToArrayBuffer(msg.authTag));
|
||||||
|
const encryptedKeyBuffer = base64ToArrayBuffer(msg.encryptedKey);
|
||||||
|
|
||||||
|
// Decrypt the AES key using RSA-OAEP.
|
||||||
|
let aesKeyRaw: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
aesKeyRaw = await window.crypto.subtle.decrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
privateKey,
|
||||||
|
encryptedKeyBuffer
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decrypt AES key:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the AES key.
|
||||||
|
const aesKey = await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
aesKeyRaw,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine ciphertext and auth tag (Web Crypto expects them appended).
|
||||||
|
const ciphertextBytes = new Uint8Array(ciphertextBuffer);
|
||||||
|
const combined = new Uint8Array(ciphertextBytes.length + authTagBuffer.length);
|
||||||
|
combined.set(ciphertextBytes);
|
||||||
|
combined.set(authTagBuffer, ciphertextBytes.length);
|
||||||
|
|
||||||
|
// Decrypt the message using AES-GCM.
|
||||||
|
let decryptedBuffer: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: ivBuffer },
|
||||||
|
aesKey,
|
||||||
|
combined.buffer
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decrypt message:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(decryptedBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
export const ErrorMessage = ({
|
export const ErrorMessage = ({
|
||||||
text,
|
text,
|
||||||
@@ -40,12 +111,7 @@ export const ErrorMessage = ({
|
|||||||
>
|
>
|
||||||
<DelayedRender delay={5500}>
|
<DelayedRender delay={5500}>
|
||||||
<Container message={message}>
|
<Container message={message}>
|
||||||
<div
|
<div className={cn('rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', className)}>
|
||||||
className={cn(
|
|
||||||
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{localize('com_ui_error_connection')}
|
{localize('com_ui_error_connection')}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -58,10 +124,7 @@ export const ErrorMessage = ({
|
|||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
className={cn(
|
className={cn('rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', className)}
|
||||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Error text={text} />
|
<Error text={text} />
|
||||||
</div>
|
</div>
|
||||||
@@ -69,41 +132,65 @@ export const ErrorMessage = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor, className = '' }: TDisplayProps) => {
|
||||||
const { isSubmitting, latestMessage } = useChatContext();
|
const { isSubmitting, latestMessage } = useChatContext();
|
||||||
|
const { user } = useAuthContext();
|
||||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||||
const showCursorState = useMemo(
|
const showCursorState = useMemo(() => showCursor === true && isSubmitting, [showCursor, isSubmitting]);
|
||||||
() => showCursor === true && isSubmitting,
|
const isLatestMessage = useMemo(() => message.messageId === latestMessage?.messageId, [message.messageId, latestMessage?.messageId]);
|
||||||
[showCursor, isSubmitting],
|
|
||||||
);
|
// State to hold the final text to display (decrypted if needed)
|
||||||
const isLatestMessage = useMemo(
|
const [displayText, setDisplayText] = useState<string>(text);
|
||||||
() => message.messageId === latestMessage?.messageId,
|
const [decryptionError, setDecryptionError] = useState<string | null>(null);
|
||||||
[message.messageId, latestMessage?.messageId],
|
|
||||||
);
|
useEffect(() => {
|
||||||
|
if (message.encryptedKey && user?.decryptedPrivateKey) {
|
||||||
|
// Attempt to decrypt the message using our helper.
|
||||||
|
decryptChatMessage(
|
||||||
|
{
|
||||||
|
text: message.text,
|
||||||
|
iv: message.iv,
|
||||||
|
authTag: message.authTag,
|
||||||
|
encryptedKey: message.encryptedKey,
|
||||||
|
},
|
||||||
|
user.decryptedPrivateKey
|
||||||
|
)
|
||||||
|
.then((plainText) => {
|
||||||
|
setDisplayText(plainText);
|
||||||
|
setDecryptionError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error decrypting message:', err);
|
||||||
|
setDecryptionError('Decryption error');
|
||||||
|
setDisplayText('');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If no encryption metadata or no private key, display plain text.
|
||||||
|
setDisplayText(text);
|
||||||
|
setDecryptionError(null);
|
||||||
|
}
|
||||||
|
}, [text, message, user]);
|
||||||
|
|
||||||
let content: React.ReactElement;
|
let content: React.ReactElement;
|
||||||
if (!isCreatedByUser) {
|
if (!isCreatedByUser) {
|
||||||
content = (
|
content = <Markdown content={displayText} showCursor={showCursorState} isLatestMessage={isLatestMessage} />;
|
||||||
<Markdown content={text} showCursor={showCursorState} isLatestMessage={isLatestMessage} />
|
|
||||||
);
|
|
||||||
} else if (enableUserMsgMarkdown) {
|
} else if (enableUserMsgMarkdown) {
|
||||||
content = <MarkdownLite content={text} />;
|
content = <MarkdownLite content={displayText} />;
|
||||||
} else {
|
} else {
|
||||||
content = <>{text}</>;
|
content = <>{displayText}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container message={message}>
|
<Container message={message}>
|
||||||
<div
|
<div className={cn(
|
||||||
className={cn(
|
isSubmitting ? 'submitting' : '',
|
||||||
isSubmitting ? 'submitting' : '',
|
showCursorState && !!displayText.length ? 'result-streaming' : '',
|
||||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
className
|
||||||
)}
|
)}>
|
||||||
>
|
{decryptionError ? <span className="text-red-500">{decryptionError}</span> : content}
|
||||||
{content}
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@@ -162,15 +249,10 @@ const MessageContent = ({
|
|||||||
{thinkingContent.length > 0 && (
|
{thinkingContent.length > 0 && (
|
||||||
<Thinking key={`thinking-${messageId}`}>{thinkingContent}</Thinking>
|
<Thinking key={`thinking-${messageId}`}>{thinkingContent}</Thinking>
|
||||||
)}
|
)}
|
||||||
<DisplayMessage
|
<DisplayMessage key={`display-${messageId}`} showCursor={showRegularCursor} text={regularContent} {...props} />
|
||||||
key={`display-${messageId}`}
|
|
||||||
showCursor={showRegularCursor}
|
|
||||||
text={regularContent}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{unfinishedMessage}
|
{unfinishedMessage}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(MessageContent);
|
export default memo(MessageContent);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import EncryptionPassphrase from './EncryptionPassphrase';
|
||||||
import MaximizeChatSpace from './MaximizeChatSpace';
|
import MaximizeChatSpace from './MaximizeChatSpace';
|
||||||
import FontSizeSelector from './FontSizeSelector';
|
import FontSizeSelector from './FontSizeSelector';
|
||||||
import SendMessageKeyEnter from './EnterToSend';
|
import SendMessageKeyEnter from './EnterToSend';
|
||||||
@@ -35,6 +36,9 @@ function Chat() {
|
|||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<ScrollButton />
|
<ScrollButton />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<EncryptionPassphrase />
|
||||||
|
</div>
|
||||||
<ForkSettings />
|
<ForkSettings />
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<ModularChat />
|
<ModularChat />
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState, ChangeEvent, FC } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
OGDialog,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogHeader,
|
||||||
|
OGDialogTitle,
|
||||||
|
Input,
|
||||||
|
} from '~/components';
|
||||||
|
import { Lock, Key } from 'lucide-react';
|
||||||
|
import { useAuthContext, useLocalize } from '~/hooks';
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import store from '~/store';
|
||||||
|
import type { TUser } from 'librechat-data-provider';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
import { useSetUserEncryptionMutation } from '~/data-provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Convert a Uint8Array to a hex string (for debugging).
|
||||||
|
*/
|
||||||
|
const uint8ArrayToHex = (array: Uint8Array): string =>
|
||||||
|
Array.from(array)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive an AES-GCM key from the passphrase using PBKDF2.
|
||||||
|
*/
|
||||||
|
const deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKey> => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyMaterial = await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(passphrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
const derivedKey = await window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
// Debug: export the derived key and log it.
|
||||||
|
const rawKey = await window.crypto.subtle.exportKey('raw', derivedKey);
|
||||||
|
console.debug('Derived key (hex):', uint8ArrayToHex(new Uint8Array(rawKey)));
|
||||||
|
return derivedKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts the user's encrypted private key using the provided passphrase.
|
||||||
|
*/
|
||||||
|
async function decryptUserPrivateKey(
|
||||||
|
encryptedPrivateKeyBase64: string,
|
||||||
|
saltBase64: string,
|
||||||
|
ivBase64: string,
|
||||||
|
passphrase: string
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
// Convert salt and IV to Uint8Array.
|
||||||
|
const salt = new Uint8Array(window.atob(saltBase64).split('').map(c => c.charCodeAt(0)));
|
||||||
|
const iv = new Uint8Array(window.atob(ivBase64).split('').map(c => c.charCodeAt(0)));
|
||||||
|
|
||||||
|
// Derive symmetric key from passphrase.
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyMaterial = await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(passphrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
const symmetricKey = await window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt the encrypted private key.
|
||||||
|
const encryptedPrivateKeyBuffer = new Uint8Array(
|
||||||
|
window.atob(encryptedPrivateKeyBase64)
|
||||||
|
.split('')
|
||||||
|
.map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
symmetricKey,
|
||||||
|
encryptedPrivateKeyBuffer
|
||||||
|
);
|
||||||
|
// Import the decrypted key as a CryptoKey.
|
||||||
|
return await window.crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
decryptedBuffer,
|
||||||
|
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||||
|
true,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserKeysSettings: FC = () => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const setUser = useSetRecoilState(store.user);
|
||||||
|
const setDecryptedPrivateKey = useSetRecoilState(store.decryptedPrivateKey);
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||||
|
const [passphrase, setPassphrase] = useState<string>('');
|
||||||
|
|
||||||
|
// Mutation hook for updating user encryption keys.
|
||||||
|
const { mutateAsync: setEncryption } = useSetUserEncryptionMutation({
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error updating encryption keys:', error);
|
||||||
|
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activateEncryption = async (): Promise<{
|
||||||
|
encryptionPublicKey: string;
|
||||||
|
encryptedPrivateKey: string;
|
||||||
|
encryptionSalt: string;
|
||||||
|
encryptionIV: string;
|
||||||
|
} | void> => {
|
||||||
|
if (!passphrase) {
|
||||||
|
console.error('Passphrase is empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
console.error('User object is missing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.debug('[Debug] Activating E2EE encryption...');
|
||||||
|
|
||||||
|
// Generate a new RSA-OAEP key pair.
|
||||||
|
const keyPair = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export the public and private keys.
|
||||||
|
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||||
|
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||||
|
const publicKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer)));
|
||||||
|
const privateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer)));
|
||||||
|
console.debug('New public key:', publicKeyBase64);
|
||||||
|
console.debug('New private key (plaintext):', privateKeyBase64);
|
||||||
|
|
||||||
|
// Generate a salt (16 bytes) and IV (12 bytes) for AES-GCM.
|
||||||
|
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
// Derive a symmetric key from the passphrase using PBKDF2.
|
||||||
|
const derivedKey = await deriveKey(passphrase, salt);
|
||||||
|
|
||||||
|
// Encrypt the private key using AES-GCM.
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const privateKeyBytes = encoder.encode(privateKeyBase64);
|
||||||
|
const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
derivedKey,
|
||||||
|
privateKeyBytes
|
||||||
|
);
|
||||||
|
const encryptedPrivateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer)));
|
||||||
|
|
||||||
|
// Convert salt and IV to Base64 strings.
|
||||||
|
const saltBase64 = window.btoa(String.fromCharCode(...salt));
|
||||||
|
const ivBase64 = window.btoa(String.fromCharCode(...iv));
|
||||||
|
|
||||||
|
console.debug('Activation complete:');
|
||||||
|
console.debug('Encrypted private key:', encryptedPrivateKeyBase64);
|
||||||
|
console.debug('Salt (base64):', saltBase64);
|
||||||
|
console.debug('IV (base64):', ivBase64);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptionPublicKey: publicKeyBase64,
|
||||||
|
encryptedPrivateKey: encryptedPrivateKeyBase64,
|
||||||
|
encryptionSalt: saltBase64,
|
||||||
|
encryptionIV: ivBase64,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during activation:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableEncryption = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await setEncryption({
|
||||||
|
encryptionPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
encryptionSalt: null,
|
||||||
|
encryptionIV: null,
|
||||||
|
});
|
||||||
|
showToast({ message: localize('com_ui_upload_success') });
|
||||||
|
setUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
encryptionPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
encryptionSalt: null,
|
||||||
|
encryptionIV: null,
|
||||||
|
}) as TUser);
|
||||||
|
setDecryptedPrivateKey(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disabling encryption:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
const newEncryption = await activateEncryption();
|
||||||
|
if (newEncryption) {
|
||||||
|
try {
|
||||||
|
await setEncryption(newEncryption);
|
||||||
|
showToast({ message: localize('com_ui_upload_success') });
|
||||||
|
setUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newEncryption,
|
||||||
|
}) as TUser);
|
||||||
|
// Decrypt the private key and store it in the atom.
|
||||||
|
const decryptedKey = await decryptUserPrivateKey(
|
||||||
|
newEncryption.encryptedPrivateKey,
|
||||||
|
newEncryption.encryptionSalt,
|
||||||
|
newEncryption.encryptionIV,
|
||||||
|
passphrase
|
||||||
|
);
|
||||||
|
setDecryptedPrivateKey(decryptedKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mutation error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
setPassphrase('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
setPassphrase(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Key className="flex w-[20px] h-[20px]" />
|
||||||
|
<span id="user-keys-label">{localize('com_nav_chat_encryption_settings')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
aria-label="Set/Change encryption keys"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
data-testid="userKeysSettings"
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||||
|
<span>{localize('com_nav_chat_change_passphrase')}</span>
|
||||||
|
</Button>
|
||||||
|
{user?.encryptionPublicKey && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
aria-label="Disable encryption"
|
||||||
|
onClick={disableEncryption}
|
||||||
|
data-testid="disableEncryption"
|
||||||
|
>
|
||||||
|
<span>Disable Encryption</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{user?.encryptionPublicKey && (
|
||||||
|
<div className="pt-2 text-xs text-gray-500">
|
||||||
|
{localize('com_nav_chat_current_public_key')}: {user.encryptionPublicKey.slice(0, 30)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OGDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
|
||||||
|
<OGDialogHeader>
|
||||||
|
<OGDialogTitle>{localize('com_nav_chat_enter_your_passphrase')}</OGDialogTitle>
|
||||||
|
</OGDialogHeader>
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={localize('com_nav_chat_passphrase_placeholder')}
|
||||||
|
aria-label={localize('com_nav_chat_enter_your_passphrase')}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={handleSubmit}>
|
||||||
|
{localize('com_ui_submit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserKeysSettings;
|
||||||
@@ -897,7 +897,7 @@ export const useUploadAssistantAvatarMutation = (
|
|||||||
unknown // context
|
unknown // context
|
||||||
> => {
|
> => {
|
||||||
return useMutation([MutationKeys.assistantAvatarUpload], {
|
return useMutation([MutationKeys.assistantAvatarUpload], {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
|
mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
|
||||||
dataService.uploadAssistantAvatar(variables),
|
dataService.uploadAssistantAvatar(variables),
|
||||||
...(options || {}),
|
...(options || {}),
|
||||||
@@ -1068,3 +1068,24 @@ export const useAcceptTermsMutation = (
|
|||||||
onMutate: options?.onMutate,
|
onMutate: options?.onMutate,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useSetUserEncryptionMutation = (
|
||||||
|
options?: {
|
||||||
|
onSuccess?: (
|
||||||
|
data: t.UpdateUserEncryptionResponse,
|
||||||
|
variables: t.UpdateUserEncryptionRequest,
|
||||||
|
context?: unknown
|
||||||
|
) => void;
|
||||||
|
onError?: (
|
||||||
|
error: unknown,
|
||||||
|
variables: t.UpdateUserEncryptionRequest,
|
||||||
|
context?: unknown
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
): UseMutationResult<t.UpdateUserEncryptionResponse, unknown, t.UpdateUserEncryptionRequest, unknown> => {
|
||||||
|
return useMutation([MutationKeys.updateUserEncryption], {
|
||||||
|
mutationFn: (variables: t.UpdateUserEncryptionRequest) =>
|
||||||
|
dataService.updateUserEncryption(variables),
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -234,6 +234,6 @@ export default function useSSE(
|
|||||||
sse.dispatchEvent(e);
|
sse.dispatchEvent(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [submission]);
|
}, [submission]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -551,6 +551,11 @@
|
|||||||
"com_ui_controls": "Controls",
|
"com_ui_controls": "Controls",
|
||||||
"com_ui_copied": "Copied!",
|
"com_ui_copied": "Copied!",
|
||||||
"com_ui_copied_to_clipboard": "Copied to clipboard",
|
"com_ui_copied_to_clipboard": "Copied to clipboard",
|
||||||
|
"com_nav_chat_encryption_settings": "Encryption Settings",
|
||||||
|
"com_nav_chat_change_passphrase": "Change Passphrase",
|
||||||
|
"com_nav_chat_enter_your_passphrase": "Enter your passphrase",
|
||||||
|
"com_nav_chat_passphrase_placeholder": "Type your encryption passphrase here...",
|
||||||
|
"com_nav_chat_current_public_key": "Current Public Key",
|
||||||
"com_ui_copy_code": "Copy code",
|
"com_ui_copy_code": "Copy code",
|
||||||
"com_ui_copy_link": "Copy link",
|
"com_ui_copy_link": "Copy link",
|
||||||
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ const availableTools = atom<Record<string, TPlugin>>({
|
|||||||
default: {},
|
default: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New atom to hold the decrypted private key (as a CryptoKey)
|
||||||
|
const decryptedPrivateKey = atom<CryptoKey | null>({
|
||||||
|
key: 'decryptedPrivateKey',
|
||||||
|
default: null,
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
user,
|
user,
|
||||||
availableTools,
|
availableTools,
|
||||||
};
|
decryptedPrivateKey,
|
||||||
|
};
|
||||||
@@ -238,10 +238,14 @@ export const userTerms = () => '/api/user/terms';
|
|||||||
export const acceptUserTerms = () => '/api/user/terms/accept';
|
export const acceptUserTerms = () => '/api/user/terms/accept';
|
||||||
export const banner = () => '/api/banner';
|
export const banner = () => '/api/banner';
|
||||||
|
|
||||||
|
|
||||||
|
export const encryption = () => '/api/user/encryption';
|
||||||
|
|
||||||
// Two-Factor Endpoints
|
// Two-Factor Endpoints
|
||||||
export const enableTwoFactor = () => '/api/auth/2fa/enable';
|
export const enableTwoFactor = () => '/api/auth/2fa/enable';
|
||||||
export const verifyTwoFactor = () => '/api/auth/2fa/verify';
|
export const verifyTwoFactor = () => '/api/auth/2fa/verify';
|
||||||
export const confirmTwoFactor = () => '/api/auth/2fa/confirm';
|
export const confirmTwoFactor = () => '/api/auth/2fa/confirm';
|
||||||
export const disableTwoFactor = () => '/api/auth/2fa/disable';
|
export const disableTwoFactor = () => '/api/auth/2fa/disable';
|
||||||
export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate';
|
export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate';
|
||||||
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
||||||
|
|
||||||
|
|||||||
@@ -775,6 +775,14 @@ export function getBanner(): Promise<t.TBannerResponse> {
|
|||||||
return request.get(endpoints.banner());
|
return request.get(endpoints.banner());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const updateUserEncryption = (
|
||||||
|
payload: t.UpdateUserEncryptionRequest,
|
||||||
|
): Promise<t.UpdateUserEncryptionResponse> => {
|
||||||
|
return request.put(endpoints.encryption(), payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
||||||
return request.get(endpoints.enableTwoFactor());
|
return request.get(endpoints.enableTwoFactor());
|
||||||
}
|
}
|
||||||
@@ -803,4 +811,4 @@ export function verifyTwoFactorTemp(
|
|||||||
payload: t.TVerify2FATempRequest,
|
payload: t.TVerify2FATempRequest,
|
||||||
): Promise<t.TVerify2FATempResponse> {
|
): Promise<t.TVerify2FATempResponse> {
|
||||||
return request.post(endpoints.verifyTwoFactorTemp(), payload);
|
return request.post(endpoints.verifyTwoFactorTemp(), payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export enum MutationKeys {
|
|||||||
deleteAgentAction = 'deleteAgentAction',
|
deleteAgentAction = 'deleteAgentAction',
|
||||||
deleteUser = 'deleteUser',
|
deleteUser = 'deleteUser',
|
||||||
updateRole = 'updateRole',
|
updateRole = 'updateRole',
|
||||||
|
updateUserEncryption = 'updateUserEncryption',
|
||||||
enableTwoFactor = 'enableTwoFactor',
|
enableTwoFactor = 'enableTwoFactor',
|
||||||
verifyTwoFactor = 'verifyTwoFactor',
|
verifyTwoFactor = 'verifyTwoFactor',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,14 +496,17 @@ export const tMessageSchema = z.object({
|
|||||||
thread_id: z.string().optional(),
|
thread_id: z.string().optional(),
|
||||||
/* frontend components */
|
/* frontend components */
|
||||||
iconURL: z.string().nullable().optional(),
|
iconURL: z.string().nullable().optional(),
|
||||||
|
iv: z.string().nullable().optional(),
|
||||||
|
authTag: z.string().nullable().optional(),
|
||||||
|
encryptedKey: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAttachmentMetadata = { messageId: string; toolCallId: string };
|
export type TAttachmentMetadata = { messageId: string; toolCallId: string };
|
||||||
export type TAttachment =
|
export type TAttachment =
|
||||||
| (TFile & TAttachmentMetadata)
|
| (TFile & TAttachmentMetadata)
|
||||||
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
|
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
} & TAttachmentMetadata);
|
} & TAttachmentMetadata);
|
||||||
|
|
||||||
export type TMessage = z.input<typeof tMessageSchema> & {
|
export type TMessage = z.input<typeof tMessageSchema> & {
|
||||||
children?: TMessage[];
|
children?: TMessage[];
|
||||||
@@ -515,6 +518,7 @@ export type TMessage = z.input<typeof tMessageSchema> & {
|
|||||||
siblingIndex?: number;
|
siblingIndex?: number;
|
||||||
attachments?: TAttachment[];
|
attachments?: TAttachment[];
|
||||||
clientTimestamp?: string;
|
clientTimestamp?: string;
|
||||||
|
messageEncryptionIV?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => {
|
export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => {
|
||||||
@@ -768,11 +772,11 @@ export const googleSchema = tConversationSchema
|
|||||||
.catch(() => ({}));
|
.catch(() => ({}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Map the following fields:
|
* TODO: Map the following fields:
|
||||||
- presence_penalty -> presencePenalty
|
- presence_penalty -> presencePenalty
|
||||||
- frequency_penalty -> frequencyPenalty
|
- frequency_penalty -> frequencyPenalty
|
||||||
- stop -> stopSequences
|
- stop -> stopSequences
|
||||||
*/
|
*/
|
||||||
export const googleGenConfigSchema = z
|
export const googleGenConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
maxOutputTokens: coerceNumber.optional(),
|
maxOutputTokens: coerceNumber.optional(),
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ export type TEndpointOption = {
|
|||||||
|
|
||||||
export type TPayload = Partial<TMessage> &
|
export type TPayload = Partial<TMessage> &
|
||||||
Partial<TEndpointOption> & {
|
Partial<TEndpointOption> & {
|
||||||
isContinued: boolean;
|
isContinued: boolean;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
messages?: TMessages;
|
messages?: TMessages;
|
||||||
isTemporary: boolean;
|
isTemporary: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSubmission = {
|
export type TSubmission = {
|
||||||
artifacts?: string;
|
artifacts?: string;
|
||||||
@@ -115,6 +115,7 @@ export type TUser = {
|
|||||||
role: string;
|
role: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
plugins?: string[];
|
plugins?: string[];
|
||||||
|
decryptedPrivateKey?: CryptoKey | string;
|
||||||
backupCodes?: TBackupCode[];
|
backupCodes?: TBackupCode[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -530,3 +531,21 @@ export type TAcceptTermsResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TBannerResponse = TBanner | null;
|
export type TBannerResponse = TBanner | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request type for updating user encryption keys.
|
||||||
|
*/
|
||||||
|
export type UpdateUserEncryptionRequest = {
|
||||||
|
encryptionPublicKey: string | null;
|
||||||
|
encryptedPrivateKey: string | null;
|
||||||
|
encryptionSalt: string | null;
|
||||||
|
encryptionIV: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response type for updating user encryption keys.
|
||||||
|
*/
|
||||||
|
export type UpdateUserEncryptionResponse = {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user