diff --git a/.env.example b/.env.example index 79b712ccb..419004125 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,13 @@ APP_TITLE=LibreChat HOST=localhost PORT=3080 +# Login and registration rate limiting. + +LOGIN_MAX=7 # The max amount of logins allowed per IP per LOGIN_WINDOW +LOGIN_WINDOW=5 # in minutes, determines how long an IP is banned for after LOGIN_MAX logins +REGISTER_MAX=5 # The max amount of registrations allowed per IP per REGISTER_WINDOW +REGISTER_WINDOW=60 # in minutes, determines how long an IP is banned for after REGISTER_MAX registrations + # Change this to proxy any API request. # It's useful if your machine has difficulty calling the original API server. # PROXY= diff --git a/api/app/bingai.js b/api/app/bingai.js index 97f47ec92..e178241c1 100644 --- a/api/app/bingai.js +++ b/api/app/bingai.js @@ -1,5 +1,6 @@ require('dotenv').config(); const { KeyvFile } = require('keyv-file'); +const { getUserKey, checkUserKeyExpiry } = require('../server/services/UserService'); const askBing = async ({ text, @@ -13,9 +14,21 @@ const askBing = async ({ clientId, invocationId, toneStyle, - token, + key: expiresAt, onProgress, + userId, }) => { + const isUserProvided = process.env.BINGAI_TOKEN === 'user_provided'; + + let key = null; + if (expiresAt && isUserProvided) { + checkUserKeyExpiry( + expiresAt, + 'Your BingAI Cookies have expired. Please provide your cookies again.', + ); + key = await getUserKey({ userId, name: 'bingAI' }); + } + const { BingAIClient } = await import('@waylaidwanderer/chatgpt-api'); const store = { store: new KeyvFile({ filename: './data/cache.json' }), @@ -24,9 +37,9 @@ const askBing = async ({ const bingAIClient = new BingAIClient({ // "_U" cookie from bing.com // userToken: - // process.env.BINGAI_TOKEN == 'user_provided' ? token : process.env.BINGAI_TOKEN ?? null, + // isUserProvided ? key : process.env.BINGAI_TOKEN ?? null, // If the above doesn't work, provide all your cookies as a string instead - cookies: process.env.BINGAI_TOKEN == 'user_provided' ? token : process.env.BINGAI_TOKEN ?? null, + cookies: isUserProvided ? key : process.env.BINGAI_TOKEN ?? null, debug: false, cache: store, host: process.env.BINGAI_HOST || null, diff --git a/api/app/chatgpt-browser.js b/api/app/chatgpt-browser.js index cf9819441..514bb22c2 100644 --- a/api/app/chatgpt-browser.js +++ b/api/app/chatgpt-browser.js @@ -1,17 +1,29 @@ require('dotenv').config(); const { KeyvFile } = require('keyv-file'); +const { getUserKey, checkUserKeyExpiry } = require('../server/services/UserService'); const browserClient = async ({ text, parentMessageId, conversationId, model, - token, + key: expiresAt, onProgress, onEventMessage, abortController, userId, }) => { + const isUserProvided = process.env.CHATGPT_TOKEN === 'user_provided'; + + let key = null; + if (expiresAt && isUserProvided) { + checkUserKeyExpiry( + expiresAt, + 'Your ChatGPT Access Token has expired. Please provide your token again.', + ); + key = await getUserKey({ userId, name: 'chatGPTBrowser' }); + } + const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api'); const store = { store: new KeyvFile({ filename: './data/cache.json' }), @@ -20,13 +32,12 @@ const browserClient = async ({ const clientOptions = { // Warning: This will expose your access token to a third party. Consider the risks before using this. reverseProxyUrl: - process.env.CHATGPT_REVERSE_PROXY || 'https://ai.fakeopen.com/api/conversation', + process.env.CHATGPT_REVERSE_PROXY ?? 'https://ai.fakeopen.com/api/conversation', // Access token from https://chat.openai.com/api/auth/session - accessToken: - process.env.CHATGPT_TOKEN == 'user_provided' ? token : process.env.CHATGPT_TOKEN ?? null, + accessToken: isUserProvided ? key : process.env.CHATGPT_TOKEN ?? null, model: model, debug: false, - proxy: process.env.PROXY || null, + proxy: process.env.PROXY ?? null, user: userId, }; @@ -37,8 +48,6 @@ const browserClient = async ({ options = { ...options, parentMessageId, conversationId }; } - console.log('gptBrowser clientOptions', clientOptions); - if (parentMessageId === '00000000-0000-0000-0000-000000000000') { delete options.conversationId; } diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index ec4344bb4..4df364b75 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -3,9 +3,9 @@ const TextStream = require('./TextStream'); const { RecursiveCharacterTextSplitter } = require('langchain/text_splitter'); const { ChatOpenAI } = require('langchain/chat_models/openai'); const { loadSummarizationChain } = require('langchain/chains'); -const { refinePrompt } = require('./prompts/refinePrompt'); const { getConvo, getMessages, saveMessage, updateMessage, saveConvo } = require('../../models'); const { addSpaceIfNeeded } = require('../../server/utils'); +const { refinePrompt } = require('./prompts'); class BaseClient { constructor(apiKey, options = {}) { @@ -55,6 +55,7 @@ class BaseClient { const { isEdited, isContinued } = opts; const user = opts.user ?? null; + this.user = user; const saveOptions = this.getSaveOptions(); this.abortController = opts.abortController ?? new AbortController(); const conversationId = opts.conversationId ?? crypto.randomUUID(); @@ -407,7 +408,6 @@ class BaseClient { const { generation = '' } = opts; - this.user = user; // It's not necessary to push to currentMessages // depending on subclass implementation of handling messages // When this is an edit, all messages are already in currentMessages, both user and response @@ -600,6 +600,14 @@ class BaseClient { // Sum the number of tokens in all properties and add `tokensPerMessage` for metadata return propertyTokenCounts.reduce((a, b) => a + b, tokensPerMessage); } + + async sendPayload(payload, opts = {}) { + if (opts && typeof opts === 'object') { + this.setOptions(opts); + } + + return await this.sendCompletion(payload, opts); + } } module.exports = BaseClient; diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 2fad6ca97..dee0ab829 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -29,7 +29,8 @@ class GoogleClient extends BaseClient { jwtClient.authorize((err) => { if (err) { - console.log(err); + console.error('Error: jwtClient failed to authorize'); + console.error(err.message); throw err; } }); @@ -247,7 +248,8 @@ class GoogleClient extends BaseClient { console.debug(result); } } catch (err) { - console.error(err); + console.error('Error: failed to send completion to Google'); + console.error(err.message); } if (!blocked) { diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 87b25b122..e3d15e66e 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -5,6 +5,8 @@ const { get_encoding: getEncoding, } = require('@dqbd/tiktoken'); const { maxTokensMap, genAzureChatCompletion } = require('../../utils'); +const { runTitleChain } = require('./chains'); +const { createLLM } = require('./llm'); // Cache to store Tiktoken instances const tokenizersCache = {}; @@ -105,6 +107,7 @@ class OpenAIClient extends BaseClient { if (this.options.reverseProxyUrl) { this.completionsUrl = this.options.reverseProxyUrl; + this.langchainProxy = this.options.reverseProxyUrl.match(/.*v1/)[0]; } else if (isChatGptModel) { this.completionsUrl = 'https://api.openai.com/v1/chat/completions'; } else { @@ -116,7 +119,7 @@ class OpenAIClient extends BaseClient { } if (this.azureEndpoint && this.options.debug) { - console.debug(`Using Azure endpoint: ${this.azureEndpoint}`, this.azure); + console.debug('Using Azure endpoint'); } return this; @@ -315,6 +318,7 @@ class OpenAIClient extends BaseClient { let reply = ''; let result = null; let streamResult = null; + this.modelOptions.user = this.user; if (typeof opts.onProgress === 'function') { await this.getCompletion( payload, @@ -373,6 +377,64 @@ class OpenAIClient extends BaseClient { content: response.text, }); } + + async titleConvo({ text, responseText = '' }) { + let title = 'New Chat'; + const convo = `||>User: +"${text}" +||>Response: +"${JSON.stringify(responseText)}"`; + + const modelOptions = { + model: 'gpt-3.5-turbo-0613', + temperature: 0.2, + presence_penalty: 0, + frequency_penalty: 0, + max_tokens: 16, + }; + + const configOptions = {}; + + if (this.langchainProxy) { + configOptions.basePath = this.langchainProxy; + } + + try { + const llm = createLLM({ + modelOptions, + configOptions, + openAIApiKey: this.apiKey, + azure: this.azure, + }); + + title = await runTitleChain({ llm, text, convo }); + } catch (e) { + console.error(e.message); + console.log('There was an issue generating title with LangChain, trying the old method...'); + modelOptions.model = 'gpt-3.5-turbo'; + const instructionsPayload = [ + { + role: 'system', + content: `Detect user language and write in the same language an extremely concise title for this conversation, which you must accurately detect. +Write in the detected language. Title in 5 Words or Less. No Punctuation or Quotation. Do not mention the language. All first letters of every word should be capitalized and write the title in User Language only. + +${convo} + +||>Title:`, + }, + ]; + + try { + title = (await this.sendPayload(instructionsPayload, { modelOptions })).replaceAll('"', ''); + } catch (e) { + console.error(e); + console.log('There was another issue generating the title, see error above.'); + } + } + + console.log('CONVERSATION TITLE', title); + return title; + } } module.exports = OpenAIClient; diff --git a/api/app/clients/PluginsClient.js b/api/app/clients/PluginsClient.js index 5dc8fa4dd..36a3dabb4 100644 --- a/api/app/clients/PluginsClient.js +++ b/api/app/clients/PluginsClient.js @@ -1,10 +1,11 @@ const OpenAIClient = require('./OpenAIClient'); const { CallbackManager } = require('langchain/callbacks'); const { HumanChatMessage, AIChatMessage } = require('langchain/schema'); -const { initializeCustomAgent, initializeFunctionsAgent } = require('./agents/'); -const { addImages, createLLM, buildErrorInput, buildPromptPrefix } = require('./agents/methods/'); -const { SelfReflectionTool } = require('./tools/'); +const { initializeCustomAgent, initializeFunctionsAgent } = require('./agents'); +const { addImages, buildErrorInput, buildPromptPrefix } = require('./output_parsers'); +const { SelfReflectionTool } = require('./tools'); const { loadTools } = require('./tools/util'); +const { createLLM } = require('./llm'); class PluginsClient extends OpenAIClient { constructor(apiKey, options = {}) { @@ -28,9 +29,9 @@ class PluginsClient extends OpenAIClient { super.setOptions(options); this.isGpt3 = this.modelOptions.model.startsWith('gpt-3'); - if (this.options.reverseProxyUrl) { - this.langchainProxy = this.options.reverseProxyUrl.match(/.*v1/)[0]; - } + // if (this.options.reverseProxyUrl) { + // this.langchainProxy = this.options.reverseProxyUrl.match(/.*v1/)[0]; + // } } getSaveOptions() { diff --git a/api/app/clients/chains/index.js b/api/app/clients/chains/index.js new file mode 100644 index 000000000..259d01d56 --- /dev/null +++ b/api/app/clients/chains/index.js @@ -0,0 +1,5 @@ +const runTitleChain = require('./runTitleChain'); + +module.exports = { + runTitleChain, +}; diff --git a/api/app/clients/chains/runTitleChain.js b/api/app/clients/chains/runTitleChain.js new file mode 100644 index 000000000..1178f1506 --- /dev/null +++ b/api/app/clients/chains/runTitleChain.js @@ -0,0 +1,43 @@ +const { z } = require('zod'); +const { langPrompt, createTitlePrompt } = require('../prompts'); +const { escapeBraces, getSnippet } = require('../output_parsers'); +const { createStructuredOutputChainFromZod } = require('langchain/chains/openai_functions'); + +const langSchema = z.object({ + language: z.string().describe('The language of the input text (full noun, no abbreviations).'), +}); + +const createLanguageChain = ({ llm }) => + createStructuredOutputChainFromZod(langSchema, { + prompt: langPrompt, + llm, + // verbose: true, + }); + +const titleSchema = z.object({ + title: z.string().describe('The title-cased title of the conversation in the given language.'), +}); +const createTitleChain = ({ llm, convo }) => { + const titlePrompt = createTitlePrompt({ convo }); + return createStructuredOutputChainFromZod(titleSchema, { + prompt: titlePrompt, + llm, + // verbose: true, + }); +}; + +const runTitleChain = async ({ llm, text, convo }) => { + let snippet = text; + try { + snippet = getSnippet(text); + } catch (e) { + console.log('Error getting snippet of text for titleChain'); + console.log(e); + } + const languageChain = createLanguageChain({ llm }); + const titleChain = createTitleChain({ llm, convo: escapeBraces(convo) }); + const { language } = await languageChain.run(snippet); + return (await titleChain.run(language)).title; +}; + +module.exports = runTitleChain; diff --git a/api/app/clients/agents/methods/createLLM.js b/api/app/clients/llm/createLLM.js similarity index 100% rename from api/app/clients/agents/methods/createLLM.js rename to api/app/clients/llm/createLLM.js diff --git a/api/app/clients/llm/index.js b/api/app/clients/llm/index.js new file mode 100644 index 000000000..4d97bfb2a --- /dev/null +++ b/api/app/clients/llm/index.js @@ -0,0 +1,5 @@ +const createLLM = require('./createLLM'); + +module.exports = { + createLLM, +}; diff --git a/api/app/clients/agents/methods/addImages.js b/api/app/clients/output_parsers/addImages.js similarity index 100% rename from api/app/clients/agents/methods/addImages.js rename to api/app/clients/output_parsers/addImages.js diff --git a/api/app/clients/output_parsers/handleInputs.js b/api/app/clients/output_parsers/handleInputs.js new file mode 100644 index 000000000..1a193e058 --- /dev/null +++ b/api/app/clients/output_parsers/handleInputs.js @@ -0,0 +1,38 @@ +// Escaping curly braces is necessary for LangChain to correctly process the prompt +function escapeBraces(str) { + return str + .replace(/({{2,})|(}{2,})/g, (match) => `${match[0]}`) + .replace(/{|}/g, (match) => `${match}${match}`); +} + +function getSnippet(text) { + let limit = 50; + let splitText = escapeBraces(text).split(' '); + + if (splitText.length === 1 && splitText[0].length > limit) { + return splitText[0].substring(0, limit); + } + + let result = ''; + let spaceCount = 0; + + for (let i = 0; i < splitText.length; i++) { + if (result.length + splitText[i].length <= limit) { + result += splitText[i] + ' '; + spaceCount++; + } else { + break; + } + + if (spaceCount == 10) { + break; + } + } + + return result.trim(); +} + +module.exports = { + escapeBraces, + getSnippet, +}; diff --git a/api/app/clients/agents/methods/handleOutputs.js b/api/app/clients/output_parsers/handleOutputs.js similarity index 96% rename from api/app/clients/agents/methods/handleOutputs.js rename to api/app/clients/output_parsers/handleOutputs.js index cda50e496..b25eaaad8 100644 --- a/api/app/clients/agents/methods/handleOutputs.js +++ b/api/app/clients/output_parsers/handleOutputs.js @@ -1,8 +1,4 @@ -const { - instructions, - imageInstructions, - errorInstructions, -} = require('../../prompts/instructions'); +const { instructions, imageInstructions, errorInstructions } = require('../prompts'); function getActions(actions = [], functionsAgent = false) { let output = 'Internal thoughts & actions taken:\n"'; diff --git a/api/app/clients/agents/methods/index.js b/api/app/clients/output_parsers/index.js similarity index 68% rename from api/app/clients/agents/methods/index.js rename to api/app/clients/output_parsers/index.js index 938210201..fdc6693d7 100644 --- a/api/app/clients/agents/methods/index.js +++ b/api/app/clients/output_parsers/index.js @@ -1,9 +1,9 @@ const addImages = require('./addImages'); -const createLLM = require('./createLLM'); +const handleInputs = require('./handleInputs'); const handleOutputs = require('./handleOutputs'); module.exports = { addImages, - createLLM, + ...handleInputs, ...handleOutputs, }; diff --git a/api/app/clients/prompts/index.js b/api/app/clients/prompts/index.js new file mode 100644 index 000000000..8cf3bb468 --- /dev/null +++ b/api/app/clients/prompts/index.js @@ -0,0 +1,9 @@ +const instructions = require('./instructions'); +const titlePrompts = require('./titlePrompts'); +const refinePrompts = require('./refinePrompts'); + +module.exports = { + ...refinePrompts, + ...instructions, + ...titlePrompts, +}; diff --git a/api/app/clients/prompts/refinePrompt.js b/api/app/clients/prompts/refinePrompts.js similarity index 100% rename from api/app/clients/prompts/refinePrompt.js rename to api/app/clients/prompts/refinePrompts.js diff --git a/api/app/clients/prompts/titlePrompts.js b/api/app/clients/prompts/titlePrompts.js new file mode 100644 index 000000000..573aff541 --- /dev/null +++ b/api/app/clients/prompts/titlePrompts.js @@ -0,0 +1,33 @@ +const { + ChatPromptTemplate, + SystemMessagePromptTemplate, + HumanMessagePromptTemplate, +} = require('langchain/prompts'); + +const langPrompt = new ChatPromptTemplate({ + promptMessages: [ + SystemMessagePromptTemplate.fromTemplate('Detect the language used in the following text.'), + HumanMessagePromptTemplate.fromTemplate('{inputText}'), + ], + inputVariables: ['inputText'], +}); + +const createTitlePrompt = ({ convo }) => { + const titlePrompt = new ChatPromptTemplate({ + promptMessages: [ + SystemMessagePromptTemplate.fromTemplate( + `Write a concise title for this conversation in the given language. Title in 5 Words or Less. No Punctuation or Quotation. All first letters of every word must be capitalized (resembling title-case), written in the given Language. +${convo}`, + ), + HumanMessagePromptTemplate.fromTemplate('Language: {language}'), + ], + inputVariables: ['language'], + }); + + return titlePrompt; +}; + +module.exports = { + langPrompt, + createTitlePrompt, +}; diff --git a/api/app/index.js b/api/app/index.js index fe1462331..be9c5e9ad 100644 --- a/api/app/index.js +++ b/api/app/index.js @@ -1,13 +1,11 @@ const { browserClient } = require('./chatgpt-browser'); const { askBing } = require('./bingai'); const clients = require('./clients'); -const titleConvo = require('./titleConvo'); const titleConvoBing = require('./titleConvoBing'); module.exports = { browserClient, askBing, - titleConvo, titleConvoBing, ...clients, }; diff --git a/api/app/titleConvo.js b/api/app/titleConvo.js deleted file mode 100644 index 65ef44d28..000000000 --- a/api/app/titleConvo.js +++ /dev/null @@ -1,57 +0,0 @@ -const throttle = require('lodash/throttle'); -const { genAzureChatCompletion, getAzureCredentials } = require('../utils/'); - -const titleConvo = async ({ text, response, openAIApiKey, azure = false }) => { - let title = 'New Chat'; - const ChatGPTClient = (await import('@waylaidwanderer/chatgpt-api')).default; - - try { - const instructionsPayload = { - role: 'system', - content: `Detect user language and write in the same language an extremely concise title for this conversation, which you must accurately detect. Write in the detected language. Title in 5 Words or Less. No Punctuation or Quotation. All first letters of every word should be capitalized and complete only the title in User Language only. - - ||>User: - "${text}" - ||>Response: - "${JSON.stringify(response?.text)}" - - ||>Title:`, - }; - - const options = { - azure, - reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null, - proxy: process.env.PROXY || null, - }; - - const titleGenClientOptions = JSON.parse(JSON.stringify(options)); - - titleGenClientOptions.modelOptions = { - model: 'gpt-3.5-turbo', - temperature: 0, - presence_penalty: 0, - frequency_penalty: 0, - }; - - let apiKey = openAIApiKey ?? process.env.OPENAI_API_KEY; - - if (azure) { - apiKey = process.env.AZURE_API_KEY; - titleGenClientOptions.reverseProxyUrl = genAzureChatCompletion(getAzureCredentials()); - } - - const titleGenClient = new ChatGPTClient(apiKey, titleGenClientOptions); - const result = await titleGenClient.getCompletion([instructionsPayload], null); - title = result.choices[0].message.content.replace(/\s+/g, ' ').replaceAll('"', '').trim(); - } catch (e) { - console.error(e); - console.log('There was an issue generating title, see error above'); - } - - console.log('CONVERSATION TITLE', title); - return title; -}; - -const throttledTitleConvo = throttle(titleConvo, 1000); - -module.exports = throttledTitleConvo; diff --git a/api/models/Message.js b/api/models/Message.js index 98ee174cc..d85f4f068 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -21,6 +21,9 @@ module.exports = { model = null, }) { try { + if (!conversationId) { + return console.log('Message not saved: no conversationId'); + } // may also need to update the conversation here await Message.findOneAndUpdate( { messageId }, diff --git a/api/models/User.js b/api/models/User.js index 5e1d035de..74cba3730 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -3,97 +3,13 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const Joi = require('joi'); const DebugControl = require('../utils/debug.js'); +const userSchema = require('./schema/userSchema.js'); function log({ title, parameters }) { DebugControl.log.functionName(title); DebugControl.log.parameters(parameters); } -const Session = mongoose.Schema({ - refreshToken: { - type: String, - default: '', - }, -}); - -const userSchema = mongoose.Schema( - { - name: { - type: String, - }, - username: { - type: String, - lowercase: true, - default: '', - }, - email: { - type: String, - required: [true, 'can\'t be blank'], - lowercase: true, - unique: true, - match: [/\S+@\S+\.\S+/, 'is invalid'], - index: true, - }, - emailVerified: { - type: Boolean, - required: true, - default: false, - }, - password: { - type: String, - trim: true, - minlength: 8, - maxlength: 128, - }, - avatar: { - type: String, - required: false, - }, - provider: { - type: String, - required: true, - default: 'local', - }, - role: { - type: String, - default: 'USER', - }, - googleId: { - type: String, - unique: true, - sparse: true, - }, - facebookId: { - type: String, - unique: true, - sparse: true, - }, - openidId: { - type: String, - unique: true, - sparse: true, - }, - githubId: { - type: String, - unique: true, - sparse: true, - }, - discordId: { - type: String, - unique: true, - sparse: true, - }, - plugins: { - type: Array, - default: [], - }, - refreshToken: { - type: [Session], - }, - }, - { timestamps: true }, -); - //Remove refreshToken from the response userSchema.set('toJSON', { transform: function (_doc, ret) { diff --git a/api/models/index.js b/api/models/index.js index a42d2c177..8f2a03c8d 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -7,8 +7,13 @@ const { } = require('./Message'); const { getConvoTitle, getConvo, saveConvo } = require('./Conversation'); const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset'); +const User = require('./User'); +const Key = require('./schema/keySchema'); module.exports = { + User, + Key, + getMessages, saveMessage, updateMessage, diff --git a/api/models/schema/keySchema.js b/api/models/schema/keySchema.js new file mode 100644 index 000000000..84b16b8a6 --- /dev/null +++ b/api/models/schema/keySchema.js @@ -0,0 +1,25 @@ +const mongoose = require('mongoose'); + +const keySchema = mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + name: { + type: String, + required: true, + }, + value: { + type: String, + required: true, + }, + expiresAt: { + type: Date, + expires: 0, + }, +}); + +keySchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +module.exports = mongoose.model('Key', keySchema); diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js new file mode 100644 index 000000000..80e635fc6 --- /dev/null +++ b/api/models/schema/userSchema.js @@ -0,0 +1,88 @@ +const mongoose = require('mongoose'); + +const Session = mongoose.Schema({ + refreshToken: { + type: String, + default: '', + }, +}); + +const userSchema = mongoose.Schema( + { + name: { + type: String, + }, + username: { + type: String, + lowercase: true, + default: '', + }, + email: { + type: String, + required: [true, 'can\'t be blank'], + lowercase: true, + unique: true, + match: [/\S+@\S+\.\S+/, 'is invalid'], + index: true, + }, + emailVerified: { + type: Boolean, + required: true, + default: false, + }, + password: { + type: String, + trim: true, + minlength: 8, + maxlength: 128, + }, + avatar: { + type: String, + required: false, + }, + provider: { + type: String, + required: true, + default: 'local', + }, + role: { + type: String, + default: 'USER', + }, + googleId: { + type: String, + unique: true, + sparse: true, + }, + facebookId: { + type: String, + unique: true, + sparse: true, + }, + openidId: { + type: String, + unique: true, + sparse: true, + }, + githubId: { + type: String, + unique: true, + sparse: true, + }, + discordId: { + type: String, + unique: true, + sparse: true, + }, + plugins: { + type: Array, + default: [], + }, + refreshToken: { + type: [Session], + }, + }, + { timestamps: true }, +); + +module.exports = userSchema; diff --git a/api/package.json b/api/package.json index 6f0b6f8a0..626149110 100644 --- a/api/package.json +++ b/api/package.json @@ -35,6 +35,8 @@ "dotenv": "^16.0.3", "eslint": "^8.41.0", "express": "^4.18.2", + "express-mongo-sanitize": "^2.2.0", + "express-rate-limit": "^6.9.0", "express-session": "^1.17.3", "googleapis": "^118.0.0", "handlebars": "^4.7.7", @@ -64,7 +66,7 @@ }, "devDependencies": { "jest": "^29.5.0", - "nodemon": "^2.0.20", + "nodemon": "^3.0.1", "path": "^0.12.7", "supertest": "^6.3.3" } diff --git a/api/server/index.js b/api/server/index.js index 03dfda120..1f43918d2 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -1,4 +1,5 @@ const express = require('express'); +const mongoSanitize = require('express-mongo-sanitize'); const connectDb = require('../lib/db/connectDb'); const indexSync = require('../lib/db/indexSync'); const path = require('path'); @@ -23,6 +24,7 @@ const startServer = async () => { // Middleware app.use(errorController); app.use(express.json({ limit: '3mb' })); + app.use(mongoSanitize()); app.use(express.urlencoded({ extended: true, limit: '3mb' })); app.use(express.static(path.join(projectPath, 'dist'))); app.use(express.static(path.join(projectPath, 'public'))); @@ -38,7 +40,7 @@ const startServer = async () => { // OAUTH app.use(passport.initialize()); passport.use(await jwtLogin()); - passport.use(await passportLogin()); + passport.use(passportLogin()); if (process.env.ALLOW_SOCIAL_LOGIN === 'true') { configureSocialLogins(app); @@ -47,6 +49,7 @@ const startServer = async () => { app.use('/oauth', routes.oauth); // API Endpoints app.use('/api/auth', routes.auth); + app.use('/api/keys', routes.keys); app.use('/api/user', routes.user); app.use('/api/search', routes.search); app.use('/api/ask', routes.ask); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 7eedc0ce4..4720ccddb 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const { saveMessage, getConvo, getConvoTitle } = require('../../models'); const { sendMessage, handleError } = require('../utils'); const abortControllers = require('./abortControllers'); @@ -73,12 +74,13 @@ const handleAbortError = async (res, req, error, data) => { const respondWithError = async () => { const errorMessage = { sender, - messageId, + messageId: messageId ?? crypto.randomUUID(), conversationId, parentMessageId, unfinished: false, cancelled: false, error: true, + final: true, text: error.message, isCreatedByUser: false, }; diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index a5c7f3c96..eb1f53870 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -1,6 +1,8 @@ const abortMiddleware = require('./abortMiddleware'); const setHeaders = require('./setHeaders'); +const loginLimiter = require('./loginLimiter'); const requireJwtAuth = require('./requireJwtAuth'); +const registerLimiter = require('./registerLimiter'); const requireLocalAuth = require('./requireLocalAuth'); const validateEndpoint = require('./validateEndpoint'); const validateMessageReq = require('./validateMessageReq'); @@ -10,7 +12,9 @@ const validateRegistration = require('./validateRegistration'); module.exports = { ...abortMiddleware, setHeaders, + loginLimiter, requireJwtAuth, + registerLimiter, requireLocalAuth, validateEndpoint, validateMessageReq, diff --git a/api/server/middleware/loginLimiter.js b/api/server/middleware/loginLimiter.js new file mode 100644 index 000000000..283e3b1b8 --- /dev/null +++ b/api/server/middleware/loginLimiter.js @@ -0,0 +1,12 @@ +const rateLimit = require('express-rate-limit'); +const windowMs = (process.env?.LOGIN_WINDOW ?? 5) * 60 * 1000; // default: 5 minutes +const max = process.env?.LOGIN_MAX ?? 7; // default: limit each IP to 7 requests per windowMs +const windowInMinutes = windowMs / 60000; + +const loginLimiter = rateLimit({ + windowMs, + max, + message: `Too many login attempts from this IP, please try again after ${windowInMinutes} minutes.`, +}); + +module.exports = loginLimiter; diff --git a/api/server/middleware/registerLimiter.js b/api/server/middleware/registerLimiter.js new file mode 100644 index 000000000..9bd3bc91f --- /dev/null +++ b/api/server/middleware/registerLimiter.js @@ -0,0 +1,12 @@ +const rateLimit = require('express-rate-limit'); +const windowMs = (process.env?.REGISTER_WINDOW ?? 60) * 60 * 1000; // default: 1 hour +const max = process.env?.REGISTER_MAX ?? 5; // default: limit each IP to 5 registrations per windowMs +const windowInMinutes = windowMs / 60000; + +const registerLimiter = rateLimit({ + windowMs, + max, + message: `Too many accounts created from this IP, please try again after ${windowInMinutes} minutes`, +}); + +module.exports = registerLimiter; diff --git a/api/server/routes/ask/anthropic.js b/api/server/routes/ask/anthropic.js index beca07b85..3517e928b 100644 --- a/api/server/routes/ask/anthropic.js +++ b/api/server/routes/ask/anthropic.js @@ -87,7 +87,7 @@ router.post( getAbortData, ); - const { client } = initializeClient(req, endpointOption); + const { client } = await initializeClient(req, endpointOption); let response = await client.sendMessage(text, { getIds, @@ -135,7 +135,7 @@ router.post( conversationId, sender: getResponseSender(endpointOption), messageId: responseMessageId, - parentMessageId: userMessageId, + parentMessageId: userMessageId ?? parentMessageId, }); } }, diff --git a/api/server/routes/ask/askChatGPTBrowser.js b/api/server/routes/ask/askChatGPTBrowser.js index 088e1da4a..b590314f9 100644 --- a/api/server/routes/ask/askChatGPTBrowser.js +++ b/api/server/routes/ask/askChatGPTBrowser.js @@ -38,7 +38,7 @@ router.post('/', requireJwtAuth, setHeaders, async (req, res) => { // build endpoint option const endpointOption = { model: req.body?.model ?? 'text-davinci-002-render-sha', - token: req.body?.token ?? null, + key: req.body?.key ?? null, }; // const availableModels = getChatGPTBrowserModels(); diff --git a/api/server/routes/ask/bingAI.js b/api/server/routes/ask/bingAI.js index 4eedb0df4..10bf55442 100644 --- a/api/server/routes/ask/bingAI.js +++ b/api/server/routes/ask/bingAI.js @@ -45,7 +45,7 @@ router.post('/', requireJwtAuth, setHeaders, async (req, res) => { systemMessage: req.body?.systemMessage ?? null, context: req.body?.context ?? null, toneStyle: req.body?.toneStyle ?? 'creative', - token: req.body?.token ?? null, + key: req.body?.key ?? null, }; } else { endpointOption = { @@ -56,7 +56,7 @@ router.post('/', requireJwtAuth, setHeaders, async (req, res) => { clientId: req.body?.clientId ?? null, invocationId: req.body?.invocationId ?? null, toneStyle: req.body?.toneStyle ?? 'creative', - token: req.body?.token ?? null, + key: req.body?.key ?? null, }; } @@ -139,6 +139,7 @@ const ask = async ({ try { let response = await askBing({ text, + userId: req.user.id, parentMessageId: userParentMessageId, conversationId: bingConversationId ?? conversationId, ...endpointOption, diff --git a/api/server/routes/ask/google.js b/api/server/routes/ask/google.js index 17775e2f3..aa77c3129 100644 --- a/api/server/routes/ask/google.js +++ b/api/server/routes/ask/google.js @@ -1,9 +1,10 @@ const express = require('express'); const router = express.Router(); const crypto = require('crypto'); -const { titleConvo, GoogleClient } = require('../../../app'); +const { GoogleClient } = require('../../../app'); const { saveMessage, getConvoTitle, saveConvo, getConvo } = require('../../../models'); const { handleError, sendMessage, createOnProgress } = require('../../utils'); +const { getUserKey, checkUserKeyExpiry } = require('../../services/UserService'); const { requireJwtAuth, setHeaders } = require('../../middleware'); router.post('/', requireJwtAuth, setHeaders, async (req, res) => { @@ -19,7 +20,7 @@ router.post('/', requireJwtAuth, setHeaders, async (req, res) => { const endpointOption = { examples: req.body?.examples ?? [{ input: { content: '' }, output: { content: '' } }], promptPrefix: req.body?.promptPrefix ?? null, - token: req.body?.token ?? null, + key: req.body?.key ?? null, modelOptions: { model: req.body?.model ?? 'chat-bison', modelLabel: req.body?.modelLabel ?? null, @@ -88,17 +89,22 @@ const ask = async ({ text, endpointOption, parentMessageId = null, conversationI const abortController = new AbortController(); + const isUserProvided = process.env.PALM_KEY === 'user_provided'; + let key; - if (endpointOption.token) { - key = JSON.parse(endpointOption.token); - delete endpointOption.token; + if (endpointOption.key && isUserProvided) { + checkUserKeyExpiry( + endpointOption.key, + 'Your GOOGLE_TOKEN has expired. Please provide your token again.', + ); + key = await getUserKey({ userId: req.user.id, name: 'google' }); + key = JSON.parse(key); + delete endpointOption.key; console.log('Using service account key provided by User for PaLM models'); } try { - if (!key) { - key = require('../../../data/auth.json'); - } + key = require('../../../data/auth.json'); } catch (e) { console.log('No \'auth.json\' file (service account key) found in /api/data/ for PaLM models'); } @@ -146,14 +152,6 @@ const ask = async ({ text, endpointOption, parentMessageId = null, conversationI responseMessage: response, }); res.end(); - - if (parentMessageId == '00000000-0000-0000-0000-000000000000') { - const title = await titleConvo({ text, response }); - await saveConvo(req.user.id, { - conversationId, - title, - }); - } } catch (error) { console.error(error); const errorMessage = { diff --git a/api/server/routes/ask/gptPlugins.js b/api/server/routes/ask/gptPlugins.js index e0d60009c..890228cef 100644 --- a/api/server/routes/ask/gptPlugins.js +++ b/api/server/routes/ask/gptPlugins.js @@ -158,7 +158,7 @@ router.post( try { endpointOption.tools = await validateTools(user, endpointOption.tools); - const { client, azure, openAIApiKey } = initializeClient(req, endpointOption); + const { client } = await initializeClient(req, endpointOption); let response = await client.sendMessage(text, { user, @@ -204,14 +204,14 @@ router.post( responseMessage: response, }); res.end(); - addTitle(req, { - text, - newConvo, - response, - openAIApiKey, - parentMessageId, - azure: !!azure, - }); + + if (parentMessageId == '00000000-0000-0000-0000-000000000000' && newConvo) { + addTitle(req, { + text, + response, + client, + }); + } } catch (error) { const partialText = getPartialText(); handleAbortError(res, req, error, { @@ -219,7 +219,7 @@ router.post( conversationId, sender: getResponseSender(endpointOption), messageId: responseMessageId, - parentMessageId: userMessageId, + parentMessageId: userMessageId ?? parentMessageId, }); } }, diff --git a/api/server/routes/ask/openAI.js b/api/server/routes/ask/openAI.js index be236956a..5e5f44508 100644 --- a/api/server/routes/ask/openAI.js +++ b/api/server/routes/ask/openAI.js @@ -94,7 +94,7 @@ router.post( ); try { - const { client, openAIApiKey } = initializeClient(req, endpointOption); + const { client } = await initializeClient(req, endpointOption); let response = await client.sendMessage(text, { user, @@ -136,14 +136,13 @@ router.post( }); res.end(); - addTitle(req, { - text, - newConvo, - response, - openAIApiKey, - parentMessageId, - azure: endpointOption.endpoint === 'azureOpenAI', - }); + if (parentMessageId == '00000000-0000-0000-0000-000000000000' && newConvo) { + addTitle(req, { + text, + response, + client, + }); + } } catch (error) { const partialText = getPartialText(); handleAbortError(res, req, error, { @@ -151,7 +150,7 @@ router.post( conversationId, sender: getResponseSender(endpointOption), messageId: responseMessageId, - parentMessageId: userMessageId, + parentMessageId: userMessageId ?? parentMessageId, }); } }, diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index ff2e7cab0..ed7d1a68c 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -7,15 +7,21 @@ const { } = require('../controllers/AuthController'); const { loginController } = require('../controllers/auth/LoginController'); const { logoutController } = require('../controllers/auth/LogoutController'); -const { requireJwtAuth, requireLocalAuth, validateRegistration } = require('../middleware'); +const { + loginLimiter, + registerLimiter, + requireJwtAuth, + requireLocalAuth, + validateRegistration, +} = require('../middleware'); const router = express.Router(); //Local router.post('/logout', requireJwtAuth, logoutController); -router.post('/login', requireLocalAuth, loginController); +router.post('/login', loginLimiter, requireLocalAuth, loginController); // router.post('/refresh', requireJwtAuth, refreshController); -router.post('/register', validateRegistration, registrationController); +router.post('/register', registerLimiter, validateRegistration, registrationController); router.post('/requestPasswordReset', resetPasswordRequestController); router.post('/resetPassword', resetPasswordController); diff --git a/api/server/routes/edit/anthropic.js b/api/server/routes/edit/anthropic.js index 10a291ea7..6f2bfc58e 100644 --- a/api/server/routes/edit/anthropic.js +++ b/api/server/routes/edit/anthropic.js @@ -87,7 +87,7 @@ router.post( getAbortData, ); - const { client } = initializeClient(req, endpointOption); + const { client } = await initializeClient(req, endpointOption); let response = await client.sendMessage(text, { user: req.user.id, @@ -136,7 +136,7 @@ router.post( conversationId, sender: getResponseSender(endpointOption), messageId: responseMessageId, - parentMessageId: userMessageId, + parentMessageId: userMessageId ?? parentMessageId, }); } }, diff --git a/api/server/routes/edit/gptPlugins.js b/api/server/routes/edit/gptPlugins.js index 89886ea82..8f635ec27 100644 --- a/api/server/routes/edit/gptPlugins.js +++ b/api/server/routes/edit/gptPlugins.js @@ -128,7 +128,7 @@ router.post( try { endpointOption.tools = await validateTools(user, endpointOption.tools); - const { client } = initializeClient(req, endpointOption); + const { client } = await initializeClient(req, endpointOption); let response = await client.sendMessage(text, { user, @@ -182,7 +182,7 @@ router.post( conversationId, sender: getResponseSender(endpointOption), messageId: responseMessageId, - parentMessageId: userMessageId, + parentMessageId: userMessageId ?? parentMessageId, }); } }, diff --git a/api/server/routes/edit/openAI.js b/api/server/routes/edit/openAI.js index 1f15d25d0..fe1c5e295 100644 --- a/api/server/routes/edit/openAI.js +++ b/api/server/routes/edit/openAI.js @@ -90,7 +90,7 @@ router.post( ); try { - const { client } = initializeClient(req, endpointOption); + const { client } = await initializeClient(req, endpointOption); let response = await client.sendMessage(text, { user: req.user.id, @@ -138,7 +138,7 @@ router.post( conversationId, sender: getResponseSender(endpointOption), messageId: responseMessageId, - parentMessageId: userMessageId, + parentMessageId: userMessageId ?? parentMessageId, }); } }, diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index bee04998e..dc5533c8b 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -8,9 +8,9 @@ const { addOpenAPISpecs } = require('../../app/clients/tools/util/addOpenAPISpec const openAIApiKey = process.env.OPENAI_API_KEY; const azureOpenAIApiKey = process.env.AZURE_API_KEY; const useAzurePlugins = !!process.env.PLUGINS_USE_AZURE; -const userProvidedOpenAI = openAIApiKey - ? openAIApiKey === 'user_provided' - : azureOpenAIApiKey === 'user_provided'; +const userProvidedOpenAI = useAzurePlugins + ? azureOpenAIApiKey === 'user_provided' + : openAIApiKey === 'user_provided'; const fetchOpenAIModels = async (opts = { azure: false, plugins: false }, _models = []) => { let models = _models.slice() ?? []; @@ -81,9 +81,6 @@ const getOpenAIModels = async (opts = { azure: false, plugins: false }) => { } if (userProvidedOpenAI) { - console.warn( - `When setting OPENAI_API_KEY to 'user_provided', ${key} must be set manually or default values will be used`, - ); return models; } @@ -161,6 +158,7 @@ router.get('/', async function (req, res) { plugins, availableAgents: ['classic', 'functions'], userProvide: userProvidedOpenAI, + azure: useAzurePlugins, } : false; const bingAI = process.env.BINGAI_TOKEN diff --git a/api/server/routes/endpoints/anthropic/initializeClient.js b/api/server/routes/endpoints/anthropic/initializeClient.js index eab0487f9..deed53ba4 100644 --- a/api/server/routes/endpoints/anthropic/initializeClient.js +++ b/api/server/routes/endpoints/anthropic/initializeClient.js @@ -1,7 +1,21 @@ const { AnthropicClient } = require('../../../../app'); +const { getUserKey, checkUserKeyExpiry } = require('../../../services/UserService'); -const initializeClient = (req) => { - let anthropicApiKey = req.body?.token ?? process.env.ANTHROPIC_API_KEY; +const initializeClient = async (req) => { + const { ANTHROPIC_API_KEY } = process.env; + const { key: expiresAt } = req.body; + + const isUserProvided = ANTHROPIC_API_KEY === 'user_provided'; + + let key = null; + if (expiresAt && isUserProvided) { + checkUserKeyExpiry( + expiresAt, + 'Your ANTHROPIC_API_KEY has expired. Please provide your API key again.', + ); + key = await getUserKey({ userId: req.user.id, name: 'anthropic' }); + } + let anthropicApiKey = isUserProvided ? key : ANTHROPIC_API_KEY; const client = new AnthropicClient(anthropicApiKey); return { client, diff --git a/api/server/routes/endpoints/gptPlugins/initializeClient.js b/api/server/routes/endpoints/gptPlugins/initializeClient.js index 6035a6f5a..428f612a0 100644 --- a/api/server/routes/endpoints/gptPlugins/initializeClient.js +++ b/api/server/routes/endpoints/gptPlugins/initializeClient.js @@ -1,22 +1,43 @@ const { PluginsClient } = require('../../../../app'); const { getAzureCredentials } = require('../../../../utils'); +const { getUserKey, checkUserKeyExpiry } = require('../../../services/UserService'); -const initializeClient = (req, endpointOption) => { +const initializeClient = async (req, endpointOption) => { + const { PROXY, OPENAI_API_KEY, AZURE_API_KEY, PLUGINS_USE_AZURE, OPENAI_REVERSE_PROXY } = + process.env; + const { key: expiresAt } = req.body; const clientOptions = { - debug: true, - reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null, - proxy: process.env.PROXY || null, + // debug: true, + reverseProxyUrl: OPENAI_REVERSE_PROXY ?? null, + proxy: PROXY ?? null, ...endpointOption, }; - let openAIApiKey = req.body?.token ?? process.env.OPENAI_API_KEY; - if (process.env.PLUGINS_USE_AZURE) { - clientOptions.azure = getAzureCredentials(); + const isUserProvided = PLUGINS_USE_AZURE + ? AZURE_API_KEY === 'user_provided' + : OPENAI_API_KEY === 'user_provided'; + + let key = null; + if (expiresAt && isUserProvided) { + checkUserKeyExpiry( + expiresAt, + 'Your OpenAI API key has expired. Please provide your API key again.', + ); + key = await getUserKey({ + userId: req.user.id, + name: PLUGINS_USE_AZURE ? 'azureOpenAI' : 'openAI', + }); + } + + let openAIApiKey = isUserProvided ? key : OPENAI_API_KEY; + + if (PLUGINS_USE_AZURE) { + clientOptions.azure = isUserProvided ? JSON.parse(key) : getAzureCredentials(); openAIApiKey = clientOptions.azure.azureOpenAIApiKey; } if (openAIApiKey && openAIApiKey.includes('azure') && !clientOptions.azure) { - clientOptions.azure = JSON.parse(req.body?.token) ?? getAzureCredentials(); + clientOptions.azure = isUserProvided ? JSON.parse(key) : getAzureCredentials(); openAIApiKey = clientOptions.azure.azureOpenAIApiKey; } const client = new PluginsClient(openAIApiKey, clientOptions); diff --git a/api/server/routes/endpoints/openAI/addTitle.js b/api/server/routes/endpoints/openAI/addTitle.js index c3f6c2ad2..87b846ba0 100644 --- a/api/server/routes/endpoints/openAI/addTitle.js +++ b/api/server/routes/endpoints/openAI/addTitle.js @@ -1,22 +1,11 @@ -const { titleConvo } = require('../../../../app'); const { saveConvo } = require('../../../../models'); -const addTitle = async ( - req, - { text, azure, response, newConvo, parentMessageId, openAIApiKey }, -) => { - if (parentMessageId == '00000000-0000-0000-0000-000000000000' && newConvo) { - const title = await titleConvo({ - text, - azure, - response, - openAIApiKey, - }); - await saveConvo(req.user.id, { - conversationId: response.conversationId, - title, - }); - } +const addTitle = async (req, { text, response, client }) => { + const title = await client.titleConvo({ text, responseText: response?.text }); + await saveConvo(req.user.id, { + conversationId: response.conversationId, + title, + }); }; module.exports = addTitle; diff --git a/api/server/routes/endpoints/openAI/initializeClient.js b/api/server/routes/endpoints/openAI/initializeClient.js index 4e4910c5b..df09a3f20 100644 --- a/api/server/routes/endpoints/openAI/initializeClient.js +++ b/api/server/routes/endpoints/openAI/initializeClient.js @@ -1,19 +1,34 @@ const { OpenAIClient } = require('../../../../app'); const { getAzureCredentials } = require('../../../../utils'); +const { getUserKey, checkUserKeyExpiry } = require('../../../services/UserService'); -const initializeClient = (req, endpointOption) => { +const initializeClient = async (req, endpointOption) => { + const { PROXY, OPENAI_API_KEY, AZURE_API_KEY, OPENAI_REVERSE_PROXY } = process.env; + const { key: expiresAt, endpoint } = req.body; const clientOptions = { // debug: true, // contextStrategy: 'refine', - reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null, - proxy: process.env.PROXY || null, + reverseProxyUrl: OPENAI_REVERSE_PROXY ?? null, + proxy: PROXY ?? null, ...endpointOption, }; - let openAIApiKey = req.body?.token ?? process.env.OPENAI_API_KEY; + const isUserProvided = + endpoint === 'openAI' ? OPENAI_API_KEY === 'user_provided' : AZURE_API_KEY === 'user_provided'; - if (process.env.AZURE_API_KEY && endpointOption.endpoint === 'azureOpenAI') { - clientOptions.azure = JSON.parse(req.body?.token) ?? getAzureCredentials(); + let key = null; + if (expiresAt && isUserProvided) { + checkUserKeyExpiry( + expiresAt, + 'Your OpenAI API key has expired. Please provide your API key again.', + ); + key = await getUserKey({ userId: req.user.id, name: endpoint }); + } + + let openAIApiKey = isUserProvided ? key : OPENAI_API_KEY; + + if (process.env.AZURE_API_KEY && endpoint === 'azureOpenAI') { + clientOptions.azure = isUserProvided ? JSON.parse(key) : getAzureCredentials(); openAIApiKey = clientOptions.azure.azureOpenAIApiKey; } diff --git a/api/server/routes/index.js b/api/server/routes/index.js index c78af3fcf..2d1315839 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -7,6 +7,7 @@ const prompts = require('./prompts'); const search = require('./search'); const tokenizer = require('./tokenizer'); const auth = require('./auth'); +const keys = require('./keys'); const oauth = require('./oauth'); const { router: endpoints } = require('./endpoints'); const plugins = require('./plugins'); @@ -22,6 +23,7 @@ module.exports = { presets, prompts, auth, + keys, oauth, user, tokenizer, diff --git a/api/server/routes/keys.js b/api/server/routes/keys.js new file mode 100644 index 000000000..cb8a4a5d9 --- /dev/null +++ b/api/server/routes/keys.js @@ -0,0 +1,35 @@ +const express = require('express'); +const router = express.Router(); +const { updateUserKey, deleteUserKey, getUserKeyExpiry } = require('../services/UserService'); +const { requireJwtAuth } = require('../middleware/'); + +router.put('/', requireJwtAuth, async (req, res) => { + await updateUserKey({ userId: req.user.id, ...req.body }); + res.status(201).send(); +}); + +router.delete('/:name', requireJwtAuth, async (req, res) => { + const { name } = req.params; + await deleteUserKey({ userId: req.user.id, name }); + res.status(204).send(); +}); + +router.delete('/', requireJwtAuth, async (req, res) => { + const { all } = req.query; + + if (all !== 'true') { + return res.status(400).send({ error: 'Specify either all=true to delete.' }); + } + + await deleteUserKey({ userId: req.user.id, all: true }); + + res.status(204).send(); +}); + +router.get('/', requireJwtAuth, async (req, res) => { + const { name } = req.query; + const response = await getUserKeyExpiry({ userId: req.user.id, name }); + res.status(200).send(response); +}); + +module.exports = router; diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index ea5837427..069ece7cb 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -1,6 +1,7 @@ const passport = require('passport'); const express = require('express'); const router = express.Router(); +const { loginLimiter } = require('../middleware'); const config = require('../../../config/loader'); const domains = config.domains; const isProduction = config.isProduction; @@ -10,6 +11,7 @@ const isProduction = config.isProduction; */ router.get( '/google', + loginLimiter, passport.authenticate('google', { scope: ['openid', 'profile', 'email'], session: false, @@ -37,6 +39,7 @@ router.get( router.get( '/facebook', + loginLimiter, passport.authenticate('facebook', { scope: ['public_profile'], profileFields: ['id', 'email', 'name'], @@ -66,6 +69,7 @@ router.get( router.get( '/openid', + loginLimiter, passport.authenticate('openid', { session: false, }), @@ -91,6 +95,7 @@ router.get( router.get( '/github', + loginLimiter, passport.authenticate('github', { scope: ['user:email', 'read:user'], session: false, @@ -118,6 +123,7 @@ router.get( router.get( '/discord', + loginLimiter, passport.authenticate('discord', { scope: ['identify', 'email'], session: false, diff --git a/api/server/services/UserService.js b/api/server/services/UserService.js index ba037be8e..c3a25f3b9 100644 --- a/api/server/services/UserService.js +++ b/api/server/services/UserService.js @@ -1,19 +1,18 @@ -const User = require('../../models/User'); +const { User, Key } = require('../../models'); +const { encrypt, decrypt } = require('../utils'); const updateUserPluginsService = async (user, pluginKey, action) => { try { if (action === 'install') { - const response = await User.updateOne( + return await User.updateOne( { _id: user._id }, { $set: { plugins: [...user.plugins, pluginKey] } }, ); - return response; } else if (action === 'uninstall') { - const response = await User.updateOne( + return await User.updateOne( { _id: user._id }, { $set: { plugins: user.plugins.filter((plugin) => plugin !== pluginKey) } }, ); - return response; } } catch (err) { console.log(err); @@ -21,4 +20,58 @@ const updateUserPluginsService = async (user, pluginKey, action) => { } }; -module.exports = { updateUserPluginsService }; +const getUserKey = async ({ userId, name }) => { + const keyValue = await Key.findOne({ userId, name }).lean(); + if (!keyValue) { + throw new Error('User-provided key not found'); + } + return decrypt(keyValue.value); +}; + +const getUserKeyExpiry = async ({ userId, name }) => { + const keyValue = await Key.findOne({ userId, name }).lean(); + if (!keyValue) { + return { expiresAt: null }; + } + return { expiresAt: keyValue.expiresAt }; +}; + +const updateUserKey = async ({ userId, name, value, expiresAt }) => { + const encryptedValue = encrypt(value); + return await Key.findOneAndUpdate( + { userId, name }, + { + userId, + name, + value: encryptedValue, + expiresAt: new Date(expiresAt), + }, + { upsert: true, new: true }, + ).lean(); +}; + +const deleteUserKey = async ({ userId, name, all = false }) => { + if (all) { + return await Key.deleteMany({ userId }); + } + + await Key.findOneAndDelete({ userId, name }).lean(); +}; + +const checkUserKeyExpiry = (expiresAt, message) => { + const expiresAtDate = new Date(expiresAt); + if (expiresAtDate < new Date()) { + const expiryStr = `User-provided key expired at ${expiresAtDate.toLocaleString()}`; + const errorMessage = message ? `${message}\n${expiryStr}` : expiryStr; + throw new Error(errorMessage); + } +}; + +module.exports = { + updateUserPluginsService, + getUserKey, + getUserKeyExpiry, + updateUserKey, + deleteUserKey, + checkUserKeyExpiry, +}; diff --git a/client/package.json b/client/package.json index 919d274f5..fece66354 100644 --- a/client/package.json +++ b/client/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-slider": "^1.1.1", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3", + "@radix-ui/react-tooltip": "^1.0.6", "@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^4.28.0", "@zattoo/use-double-click": "1.2.0", diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 34423ee7e..63635d84b 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -1,4 +1,4 @@ -import { TConversation, TMessage, TPreset } from 'librechat-data-provider'; +import type { TConversation, TMessage, TPreset, TMutation } from 'librechat-data-provider'; export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void; export type TSetExample = ( @@ -120,3 +120,29 @@ export type TDisplayProps = TText & Pick & { showCursor?: boolean; }; + +export type TConfigProps = { + userKey: string; + setUserKey: React.Dispatch>; + endpoint: string; +}; + +export type TDangerButtonProps = { + id: string; + confirmClear: boolean; + className?: string; + disabled?: boolean; + showText?: boolean; + mutation?: TMutation; + onClick: () => void; + infoTextCode: string; + actionTextCode: string; + dataTestIdInitial: string; + dataTestIdConfirm: string; + confirmActionTextCode?: string; +}; + +export type TDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 025051da5..6e2616c24 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -30,7 +30,9 @@ function Login() { className="relative mt-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700" role="alert" > - {localize('com_auth_error_login')} + {error?.includes('429') + ? localize('com_auth_error_login_rl') + : localize('com_auth_error_login')} )} diff --git a/client/src/components/Endpoints/EditPresetDialog.tsx b/client/src/components/Endpoints/EditPresetDialog.tsx index 29140f7f1..2e11d7851 100644 --- a/client/src/components/Endpoints/EditPresetDialog.tsx +++ b/client/src/components/Endpoints/EditPresetDialog.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import filenamify from 'filenamify'; import exportFromJSON from 'export-from-json'; import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil'; -import { TEditPresetProps } from '~/common'; +import type { TEditPresetProps } from '~/common'; import { useSetOptions, useLocalize } from '~/hooks'; import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/'; import DialogTemplate from '~/components/ui/DialogTemplate'; diff --git a/client/src/components/Endpoints/Settings/AgentSettings.tsx b/client/src/components/Endpoints/Settings/AgentSettings.tsx index 2818abb7a..1885465e0 100644 --- a/client/src/components/Endpoints/Settings/AgentSettings.tsx +++ b/client/src/components/Endpoints/Settings/AgentSettings.tsx @@ -1,4 +1,5 @@ -import { TModelSelectProps, ESide } from '~/common'; +import type { TModelSelectProps } from '~/common'; +import { ESide } from '~/common'; import { Switch, SelectDropDown, diff --git a/client/src/components/Endpoints/Settings/Anthropic.tsx b/client/src/components/Endpoints/Settings/Anthropic.tsx index e9348f776..156c61036 100644 --- a/client/src/components/Endpoints/Settings/Anthropic.tsx +++ b/client/src/components/Endpoints/Settings/Anthropic.tsx @@ -1,6 +1,7 @@ import React from 'react'; import TextareaAutosize from 'react-textarea-autosize'; -import { ESide, TModelSelectProps } from '~/common'; +import type { TModelSelectProps } from '~/common'; +import { ESide } from '~/common'; import { Input, Label, diff --git a/client/src/components/Endpoints/Settings/Google.tsx b/client/src/components/Endpoints/Settings/Google.tsx index 14e85cda9..07507f86c 100644 --- a/client/src/components/Endpoints/Settings/Google.tsx +++ b/client/src/components/Endpoints/Settings/Google.tsx @@ -1,6 +1,7 @@ import React from 'react'; import TextareaAutosize from 'react-textarea-autosize'; -import { ESide, TModelSelectProps } from '~/common'; +import type { TModelSelectProps } from '~/common'; +import { ESide } from '~/common'; import { SelectDropDown, Input, diff --git a/client/src/components/Endpoints/Settings/OpenAI.tsx b/client/src/components/Endpoints/Settings/OpenAI.tsx index d6090bb0a..4740dfe17 100644 --- a/client/src/components/Endpoints/Settings/OpenAI.tsx +++ b/client/src/components/Endpoints/Settings/OpenAI.tsx @@ -1,5 +1,6 @@ import TextareaAutosize from 'react-textarea-autosize'; -import { ESide, TModelSelectProps } from '~/common'; +import type { TModelSelectProps } from '~/common'; +import { ESide } from '~/common'; import { SelectDropDown, Input, diff --git a/client/src/components/Endpoints/Settings/Plugins.tsx b/client/src/components/Endpoints/Settings/Plugins.tsx index 3562abf24..38dbe89f9 100644 --- a/client/src/components/Endpoints/Settings/Plugins.tsx +++ b/client/src/components/Endpoints/Settings/Plugins.tsx @@ -9,7 +9,8 @@ import { HoverCardTrigger, } from '~/components'; import OptionHover from './OptionHover'; -import { ESide, TModelSelectProps } from '~/common'; +import type { TModelSelectProps } from '~/common'; +import { ESide } from '~/common'; import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/'; import { useLocalize } from '~/hooks'; diff --git a/client/src/components/Input/EndpointMenu/EndpointItem.tsx b/client/src/components/Input/EndpointMenu/EndpointItem.tsx index 38e822a9f..fc68a01b7 100644 --- a/client/src/components/Input/EndpointMenu/EndpointItem.tsx +++ b/client/src/components/Input/EndpointMenu/EndpointItem.tsx @@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil'; import { Settings } from 'lucide-react'; import { DropdownMenuRadioItem } from '~/components'; import { getIcon } from '~/components/Endpoints'; -import { SetTokenDialog } from '../SetTokenDialog'; +import { SetKeyDialog } from '../SetKeyDialog'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -18,7 +18,7 @@ export default function ModelItem({ value: string; isSelected: boolean; }) { - const [setTokenDialogOpen, setSetTokenDialogOpen] = useState(false); + const [isDialogOpen, setDialogOpen] = useState(false); const endpointsConfig = useRecoilValue(store.endpointsConfig); const icon = getIcon({ @@ -29,7 +29,7 @@ export default function ModelItem({ message: false, }); - const isUserProvided = endpointsConfig?.[endpoint]?.userProvide; + const userProvidesKey = endpointsConfig?.[endpoint]?.userProvide; const localize = useLocalize(); // regular model @@ -52,7 +52,7 @@ export default function ModelItem({ )}
- {isUserProvided ? ( + {userProvidesKey ? ( ) : null} - + {userProvidesKey && ( + + )} ); } diff --git a/client/src/components/Input/EndpointMenu/EndpointMenu.jsx b/client/src/components/Input/EndpointMenu/EndpointMenu.jsx index dbf0cab4f..3e4493308 100644 --- a/client/src/components/Input/EndpointMenu/EndpointMenu.jsx +++ b/client/src/components/Input/EndpointMenu/EndpointMenu.jsx @@ -17,20 +17,24 @@ import { DropdownMenuTrigger, Dialog, DialogTrigger, + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, } from '~/components/ui/'; import DialogTemplate from '~/components/ui/DialogTemplate'; import { cn, cleanupPreset, getDefaultConversation } from '~/utils'; -import { useLocalize } from '~/hooks'; - +import { useLocalize, useLocalStorage } from '~/hooks'; import store from '~/store'; export default function NewConversationMenu() { + const localize = useLocalize(); const [menuOpen, setMenuOpen] = useState(false); const [showPresets, setShowPresets] = useState(true); const [showEndpoints, setShowEndpoints] = useState(true); const [presetModelVisible, setPresetModelVisible] = useState(false); const [preset, setPreset] = useState(false); - const [conversation, setConversation] = useRecoilState(store.conversation) || {}; + const [conversation, setConversation] = useRecoilState(store.conversation) ?? {}; const [messages, setMessages] = useRecoilState(store.messages); const availableEndpoints = useRecoilValue(store.availableEndpoints); const endpointsConfig = useRecoilValue(store.endpointsConfig); @@ -71,24 +75,17 @@ export default function NewConversationMenu() { } }, [availableEndpoints]); - // save selected model to localStorage + // save states to localStorage + const [newUser, setNewUser] = useLocalStorage('newUser', true); + const [lastModel, setLastModel] = useLocalStorage('lastSelectedModel', {}); + const setLastConvo = useLocalStorage('lastConversationSetup', {})[1]; + const [lastBingSettings, setLastBingSettings] = useLocalStorage('lastBingSettings', {}); useEffect(() => { - if (endpoint) { - const lastSelectedModel = JSON.parse(localStorage.getItem('lastSelectedModel')) || {}; - localStorage.setItem( - 'lastSelectedModel', - JSON.stringify({ ...lastSelectedModel, [endpoint]: conversation.model }), - ); - localStorage.setItem('lastConversationSetup', JSON.stringify(conversation)); - } - - if (endpoint === 'bingAI') { - const lastBingSettings = JSON.parse(localStorage.getItem('lastBingSettings')) || {}; + if (endpoint && endpoint !== 'bingAI') { + setLastModel({ ...lastModel, [endpoint]: conversation?.model }), setLastConvo(conversation); + } else if (endpoint === 'bingAI') { const { jailbreak, toneStyle } = conversation; - localStorage.setItem( - 'lastBingSettings', - JSON.stringify({ ...lastBingSettings, jailbreak, toneStyle }), - ); + setLastBingSettings({ ...lastBingSettings, jailbreak, toneStyle }); } }, [conversation]); @@ -150,124 +147,131 @@ export default function NewConversationMenu() { button: true, }); - const localize = useLocalize(); + const onOpenChange = (open) => { + setMenuOpen(open); + if (newUser) { + setNewUser(false); + } + }; return ( - - - - - - event.preventDefault()} - > - setShowEndpoints((prev) => !prev)} - > - {showEndpoints ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '} - {localize('com_endpoint')} - - - - {showEndpoints && - (availableEndpoints.length ? ( - - ) : ( - - {localize('com_endpoint_not_available')} - - ))} - - -
- - - setShowPresets((prev) => !prev)} - > - {showPresets ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '} - {localize('com_endpoint_presets')} - - - - - - - - - - - - {showPresets && - (presets.length ? ( - - ) : ( - - {localize('com_endpoint_no_presets')} - - ))} - - - - -
+ {icon} + + + + + {localize('com_endpoint_open_menu')} + + event.preventDefault()} + side="top" + > + setShowEndpoints((prev) => !prev)} + > + {showEndpoints ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '} + {localize('com_endpoint')} + + + + {showEndpoints && + (availableEndpoints.length ? ( + + ) : ( + + {localize('com_endpoint_not_available')} + + ))} + + +
+ + + setShowPresets((prev) => !prev)} + > + {showPresets ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '} + {localize('com_endpoint_presets')} + + + + + + + + + + + + {showPresets && + (presets.length ? ( + + ) : ( + + {localize('com_endpoint_no_presets')} + + ))} + + + + + + + ); } diff --git a/client/src/components/Input/EndpointMenu/FileUpload.tsx b/client/src/components/Input/EndpointMenu/FileUpload.tsx index ef76866c5..aeed31c51 100644 --- a/client/src/components/Input/EndpointMenu/FileUpload.tsx +++ b/client/src/components/Input/EndpointMenu/FileUpload.tsx @@ -51,6 +51,15 @@ const FileUpload: React.FC = ({ reader.readAsText(file); }; + let statusText: string; + if (!status) { + statusText = text ?? localize('com_endpoint_import'); + } else if (status === 'success') { + statusText = successText ?? localize('com_ui_upload_success'); + } else { + statusText = invalidText ?? localize('com_ui_upload_invalid'); + } + return (
+ } + selection={{ + selectHandler: submit, + selectClasses: 'bg-green-600 hover:bg-green-700 dark:hover:bg-green-800 text-white', + selectText: localize('com_ui_submit'), + }} + leftButtons={ + + } + /> + + ); +}; + +export default SetKeyDialog; diff --git a/client/src/components/Input/SetKeyDialog/index.ts b/client/src/components/Input/SetKeyDialog/index.ts new file mode 100644 index 000000000..04e9f83eb --- /dev/null +++ b/client/src/components/Input/SetKeyDialog/index.ts @@ -0,0 +1 @@ +export { default as SetKeyDialog } from './SetKeyDialog'; diff --git a/client/src/components/Input/SetTokenDialog/GoogleConfig.tsx b/client/src/components/Input/SetTokenDialog/GoogleConfig.tsx deleted file mode 100644 index 4a5b9081d..000000000 --- a/client/src/components/Input/SetTokenDialog/GoogleConfig.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import FileUpload from '../EndpointMenu/FileUpload'; -import { useLocalize } from '~/hooks'; - -const GoogleConfig = ({ setToken }: { setToken: React.Dispatch> }) => { - const localize = useLocalize(); - return ( - { - if (!credentials) { - return false; - } - - if ( - !credentials.client_email || - typeof credentials.client_email !== 'string' || - credentials.client_email.length <= 2 - ) { - return false; - } - - if ( - !credentials.project_id || - typeof credentials.project_id !== 'string' || - credentials.project_id.length <= 2 - ) { - return false; - } - - if ( - !credentials.private_key || - typeof credentials.private_key !== 'string' || - credentials.private_key.length <= 600 - ) { - return false; - } - - return true; - }} - onFileSelected={(data) => { - setToken(JSON.stringify(data)); - }} - /> - ); -}; - -export default GoogleConfig; diff --git a/client/src/components/Input/SetTokenDialog/OtherConfig.tsx b/client/src/components/Input/SetTokenDialog/OtherConfig.tsx deleted file mode 100644 index 32de11607..000000000 --- a/client/src/components/Input/SetTokenDialog/OtherConfig.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import InputWithLabel from './InputWithLabel'; -import { useLocalize } from '~/hooks'; - -type ConfigProps = { - token: string; - setToken: React.Dispatch>; -}; - -const OtherConfig = ({ token, setToken }: ConfigProps) => { - const localize = useLocalize(); - return ( - ) => setToken(e.target.value || '')} - label={localize('com_endpoint_config_token_name')} - /> - ); -}; - -export default OtherConfig; diff --git a/client/src/components/Input/SetTokenDialog/SetTokenDialog.tsx b/client/src/components/Input/SetTokenDialog/SetTokenDialog.tsx deleted file mode 100644 index 1d6055e9f..000000000 --- a/client/src/components/Input/SetTokenDialog/SetTokenDialog.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useState } from 'react'; -import HelpText from './HelpText'; -import GoogleConfig from './GoogleConfig'; -import OpenAIConfig from './OpenAIConfig'; -import OtherConfig from './OtherConfig'; -import { Dialog } from '~/components/ui'; -import DialogTemplate from '~/components/ui/DialogTemplate'; -import { alternateName } from '~/utils'; -import store from '~/store'; -import { useLocalize } from '~/hooks'; - -const SetTokenDialog = ({ open, onOpenChange, endpoint }) => { - const localize = useLocalize(); - const [token, setToken] = useState(''); - const { saveToken } = store.useToken(endpoint); - - const submit = () => { - saveToken(token); - onOpenChange(false); - }; - - const endpointComponents = { - google: GoogleConfig, - openAI: OpenAIConfig, - azureOpenAI: OpenAIConfig, - gptPlugins: OpenAIConfig, - default: OtherConfig, - }; - - const EndpointComponent = endpointComponents[endpoint] || endpointComponents['default']; - - return ( - - - - {localize('com_endpoint_config_token_server')} - -
- } - selection={{ - selectHandler: submit, - selectClasses: 'bg-green-600 hover:bg-green-700 dark:hover:bg-green-800 text-white', - selectText: localize('com_ui_submit'), - }} - /> - - ); -}; - -export default SetTokenDialog; diff --git a/client/src/components/Input/SetTokenDialog/index.ts b/client/src/components/Input/SetTokenDialog/index.ts deleted file mode 100644 index ee85de11c..000000000 --- a/client/src/components/Input/SetTokenDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as SetTokenDialog } from './SetTokenDialog'; diff --git a/client/src/components/Input/SubmitButton.jsx b/client/src/components/Input/SubmitButton.tsx similarity index 68% rename from client/src/components/Input/SubmitButton.jsx rename to client/src/components/Input/SubmitButton.tsx index d5c476bec..ae75386a4 100644 --- a/client/src/components/Input/SubmitButton.jsx +++ b/client/src/components/Input/SubmitButton.tsx @@ -1,33 +1,43 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { StopGeneratingIcon } from '~/components'; import { Settings } from 'lucide-react'; -import { SetTokenDialog } from './SetTokenDialog'; -import store from '~/store'; -import { useLocalize } from '~/hooks'; +import { SetKeyDialog } from './SetKeyDialog'; +import { useUserKey, useLocalize } from '~/hooks'; export default function SubmitButton({ - endpoint, + conversation, submitMessage, handleStopGenerating, disabled, isSubmitting, - endpointsConfig, + userProvidesKey, }) { - const [setTokenDialogOpen, setSetTokenDialogOpen] = useState(false); - const { getToken } = store.useToken(endpoint); - - const isTokenProvided = endpointsConfig?.[endpoint]?.userProvide ? !!getToken() : true; - const endpointsToHideSetTokens = new Set(['openAI', 'azureOpenAI', 'bingAI']); + const { endpoint } = conversation; + const [isDialogOpen, setDialogOpen] = useState(false); + const { checkExpiry } = useUserKey(endpoint); + const [isKeyProvided, setKeyProvided] = useState(userProvidesKey ? checkExpiry() : true); + const isKeyActive = checkExpiry(); const localize = useLocalize(); - const clickHandler = (e) => { - e.preventDefault(); - submitMessage(); - }; + useEffect(() => { + if (userProvidesKey) { + setKeyProvided(isKeyActive); + } else { + setKeyProvided(true); + } + }, [checkExpiry, endpoint, userProvidesKey, isKeyActive]); - const setToken = () => { - setSetTokenDialogOpen(true); - }; + const clickHandler = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + submitMessage(); + }, + [submitMessage], + ); + + const setKey = useCallback(() => { + setDialogOpen(true); + }, []); if (isSubmitting) { return ( @@ -41,26 +51,24 @@ export default function SubmitButton({ ); - } else if (!isTokenProvided && !endpointsToHideSetTokens.has(endpoint)) { + } else if (!isKeyProvided) { return ( <> - + {userProvidesKey && ( + + )} ); } else { @@ -68,6 +76,7 @@ export default function SubmitButton({ */} - - + ); }; @@ -127,6 +98,9 @@ function General() { const { newConversation } = store.useConversation(); const { refreshConversations } = store.useConversations(); + const contentRef = useRef(null); + useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []); + useEffect(() => { if (clearConvosMutation.isSuccess) { newConversation(); @@ -159,7 +133,12 @@ function General() { ); return ( - +
@@ -168,7 +147,12 @@ function General() {
- +
diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 53f4efa35..939c90f3b 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -1,2 +1,4 @@ export { default as General } from './General'; export { ClearChatsButton } from './General'; +export { default as Data } from './Data'; +export { RevokeKeysButton } from './Data'; diff --git a/client/src/components/svg/CogIcon.tsx b/client/src/components/svg/CogIcon.tsx index 4a0ac8736..969601ff8 100644 --- a/client/src/components/svg/CogIcon.tsx +++ b/client/src/components/svg/CogIcon.tsx @@ -1,13 +1,16 @@ -import * as React from 'react'; +import { cn } from '~/utils'; -export default function CogIcon() { +export default function CogIcon({ className = '' }) { return ( + + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 2f4c2289c..b4967aac2 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -2,6 +2,7 @@ export { default as Plugin } from './Plugin'; export { default as GPTIcon } from './GPTIcon'; export { default as EditIcon } from './EditIcon'; export { default as CogIcon } from './CogIcon'; +export { default as DataIcon } from './DataIcon'; export { default as Panel } from './Panel'; export { default as Spinner } from './Spinner'; export { default as Clipboard } from './Clipboard'; diff --git a/client/src/components/ui/Dropdown.jsx b/client/src/components/ui/Dropdown.jsx index d31a8cb39..5573b0a2c 100644 --- a/client/src/components/ui/Dropdown.jsx +++ b/client/src/components/ui/Dropdown.jsx @@ -3,7 +3,15 @@ import CheckMark from '../svg/CheckMark'; import { Listbox } from '@headlessui/react'; import { cn } from '~/utils/'; -function Dropdown({ value, onChange, options, className, containerClassName }) { +function Dropdown({ + value, + label = '', + onChange, + options, + className, + containerClassName, + optionsClassName = '', +}) { const currentOption = options.find((element) => element === value || element?.value === value) ?? value; return ( @@ -18,7 +26,7 @@ function Dropdown({ value, onChange, options, className, containerClassName }) { > - {currentOption?.display ?? value} + {`${label}${currentOption?.display ?? value}`} @@ -38,12 +46,17 @@ function Dropdown({ value, onChange, options, className, containerClassName }) { - + {options.map((item, i) => ( {!option.isButton && ( diff --git a/client/src/components/ui/SelectDropDown.tsx b/client/src/components/ui/SelectDropDown.tsx index 262c4275d..2bdc9940d 100644 --- a/client/src/components/ui/SelectDropDown.tsx +++ b/client/src/components/ui/SelectDropDown.tsx @@ -107,7 +107,7 @@ function SelectDropDown({ , + React.ComponentPropsWithoutRef +>((props, ref) => ); +TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName; + +const TooltipPortal = TooltipPrimitive.Portal; + +const TooltipArrow = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ); +TooltipArrow.displayName = TooltipPrimitive.Arrow.displayName; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className = '', forceMount, children, ...props }, ref) => ( + + + + {children} + + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +const TooltipProvider = TooltipPrimitive.Provider; + +export { Tooltip, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow, TooltipProvider }; diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 31b90e39d..6686cbaf5 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -14,6 +14,7 @@ export * from './Switch'; export * from './Tabs'; export * from './Templates'; export * from './Textarea'; +export * from './Tooltip'; export { default as Dropdown } from './Dropdown'; export { default as SelectDropDown } from './SelectDropDown'; export { default as MultiSelectDropDown } from './MultiSelectDropDown'; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 55f19eebe..746f44cdd 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -2,11 +2,14 @@ export * from './AuthContext'; export * from './ThemeContext'; export * from './ScreenshotContext'; export * from './ApiErrorBoundaryContext'; +export { default as useUserKey } from './useUserKey'; export { default as useDebounce } from './useDebounce'; export { default as useLocalize } from './useLocalize'; export { default as useMediaQuery } from './useMediaQuery'; export { default as useSetOptions } from './useSetOptions'; export { default as useGenerations } from './useGenerations'; export { default as useScrollToRef } from './useScrollToRef'; +export { default as useLocalStorage } from './useLocalStorage'; export { default as useServerStream } from './useServerStream'; +export { default as useOnClickOutside } from './useOnClickOutside'; export { default as useMessageHandler } from './useMessageHandler'; diff --git a/client/src/hooks/useLocalStorage.tsx b/client/src/hooks/useLocalStorage.tsx new file mode 100644 index 000000000..87ee5d461 --- /dev/null +++ b/client/src/hooks/useLocalStorage.tsx @@ -0,0 +1,53 @@ +/* `useLocalStorage` + * + * Features: + * - JSON Serializing + * - Also value will be updated everywhere, when value updated (via `storage` event) + */ + +import { useEffect, useState } from 'react'; + +export default function useLocalStorage(key: string, defaultValue: T): [T, (value: T) => void] { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + const item = localStorage.getItem(key); + + if (!item) { + localStorage.setItem(key, JSON.stringify(defaultValue)); + } + + setValue(item ? JSON.parse(item) : defaultValue); + + function handler(e: StorageEvent) { + if (e.key !== key) { + return; + } + + const lsi = localStorage.getItem(key); + setValue(JSON.parse(lsi ?? '')); + } + + window.addEventListener('storage', handler); + + return () => { + window.removeEventListener('storage', handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setValueWrap = (value: T) => { + try { + setValue(value); + + localStorage.setItem(key, JSON.stringify(value)); + if (typeof window !== 'undefined') { + window.dispatchEvent(new StorageEvent('storage', { key })); + } + } catch (e) { + console.error(e); + } + }; + + return [value, setValueWrap]; +} diff --git a/client/src/hooks/useMessageHandler.ts b/client/src/hooks/useMessageHandler.ts index 4c491f03f..f031c250a 100644 --- a/client/src/hooks/useMessageHandler.ts +++ b/client/src/hooks/useMessageHandler.ts @@ -3,6 +3,7 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { parseConvo, getResponseSender } from 'librechat-data-provider'; import type { TMessage, TSubmission } from 'librechat-data-provider'; import type { TAskFunction } from '~/common'; +import useUserKey from './useUserKey'; import store from '~/store'; const useMessageHandler = () => { @@ -16,7 +17,7 @@ const useMessageHandler = () => { const endpointsConfig = useRecoilValue(store.endpointsConfig); const [messages, setMessages] = useRecoilState(store.messages); const { endpoint } = currentConversation; - const { getToken } = store.useToken(endpoint ?? ''); + const { getExpiry } = useUserKey(endpoint ?? ''); const ask: TAskFunction = ( { text, parentMessageId = null, conversationId = null, messageId = null }, @@ -49,14 +50,13 @@ const useMessageHandler = () => { } const isEditOrContinue = isEdited || isContinued; - const { userProvide } = endpointsConfig[endpoint] ?? {}; // set the endpoint option const convo = parseConvo(endpoint, currentConversation); const endpointOption = { endpoint, ...convo, - token: userProvide ? getToken() : null, + key: getExpiry(), }; const responseSender = getResponseSender(endpointOption); diff --git a/client/src/hooks/useServerStream.ts b/client/src/hooks/useServerStream.ts index 32d339357..054283376 100644 --- a/client/src/hooks/useServerStream.ts +++ b/client/src/hooks/useServerStream.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useResetRecoilState, useSetRecoilState } from 'recoil'; +/* @ts-ignore */ import { SSE, createPayload, tMessageSchema, tConversationSchema } from 'librechat-data-provider'; import type { TResPlugin, TMessage, TConversation, TSubmission } from 'librechat-data-provider'; import { useAuthContext } from '~/hooks/AuthContext'; diff --git a/client/src/hooks/useUserKey.ts b/client/src/hooks/useUserKey.ts new file mode 100644 index 000000000..d718926fa --- /dev/null +++ b/client/src/hooks/useUserKey.ts @@ -0,0 +1,58 @@ +import { useRecoilValue } from 'recoil'; +import { useMemo, useCallback } from 'react'; +import { useUpdateUserKeysMutation, useUserKeyQuery } from 'librechat-data-provider'; +import store from '~/store'; + +const useUserKey = (endpoint: string) => { + const endpointsConfig = useRecoilValue(store.endpointsConfig); + const config = endpointsConfig[endpoint]; + + const { azure } = config ?? {}; + let keyEndpoint = endpoint; + + if (azure) { + keyEndpoint = 'azureOpenAI'; + } else if (keyEndpoint === 'gptPlugins') { + keyEndpoint = 'openAI'; + } + + const updateKey = useUpdateUserKeysMutation(); + const checkUserKey = useUserKeyQuery(keyEndpoint); + const getExpiry = useCallback(() => { + if (checkUserKey.data) { + return checkUserKey.data.expiresAt; + } + }, [checkUserKey.data]); + + const checkExpiry = useCallback(() => { + const expiresAt = getExpiry(); + if (!expiresAt) { + return false; + } + + const expiresAtDate = new Date(expiresAt); + if (expiresAtDate < new Date()) { + return false; + } + return true; + }, [getExpiry]); + + const saveUserKey = useCallback( + (value: string, expiresAt: number) => { + const dateStr = new Date(expiresAt).toISOString(); + updateKey.mutate({ + name: keyEndpoint, + value, + expiresAt: dateStr, + }); + }, + [updateKey, keyEndpoint], + ); + + return useMemo( + () => ({ getExpiry, checkExpiry, saveUserKey }), + [getExpiry, checkExpiry, saveUserKey], + ); +}; + +export default useUserKey; diff --git a/client/src/localization/languages/Br.tsx b/client/src/localization/languages/Br.tsx index ec22c3eee..2133d7a2f 100644 --- a/client/src/localization/languages/Br.tsx +++ b/client/src/localization/languages/Br.tsx @@ -84,6 +84,7 @@ export default { com_auth_to_try_again: 'para tentar novamente.', com_auth_submit_registration: 'Enviar registro', com_auth_welcome_back: 'Bem-vindo(a) de volta', + com_endpoint_open_menu: 'Abrir Menu', com_endpoint_bing_enable_sydney: 'Habilitar Sydney', com_endpoint_bing_to_enable_sydney: 'Para habilitar Sydney', com_endpoint_bing_jailbreak: 'Jailbreak', diff --git a/client/src/localization/languages/De.tsx b/client/src/localization/languages/De.tsx index 0354df3d2..62813e1bb 100644 --- a/client/src/localization/languages/De.tsx +++ b/client/src/localization/languages/De.tsx @@ -81,6 +81,7 @@ export default { com_auth_to_try_again: 'um es nochmal zu versuchen.', com_auth_submit_registration: 'Registrieren', com_auth_welcome_back: 'Willkommen zurück!', + com_endpoint_open_menu: 'Öffne Menü', com_endpoint_bing_enable_sydney: 'Aktiviere Sydney', com_endpoint_bing_to_enable_sydney: 'Um Sydney zu aktivieren', com_endpoint_bing_jailbreak: 'Jailbreak', diff --git a/client/src/localization/languages/Eng.tsx b/client/src/localization/languages/Eng.tsx index 047e617b2..305f81b67 100644 --- a/client/src/localization/languages/Eng.tsx +++ b/client/src/localization/languages/Eng.tsx @@ -30,6 +30,8 @@ export default { com_ui_pay_per_call: 'All AI conversations in one place. Pay per call and not per month', 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_cancel: 'Cancel', com_ui_save: 'Save', com_ui_copy_to_clipboard: 'Copy to clipboard', @@ -40,12 +42,17 @@ export default { com_ui_success: 'Success', com_ui_all: 'all', com_ui_clear: 'Clear', + com_ui_revoke: 'Revoke', + com_ui_revoke_info: 'Revoke all user provided credentials.', + com_ui_confirm_action: 'Confirm Action', com_ui_chats: 'chats', com_ui_delete: 'Delete', com_ui_delete_conversation: 'Delete chat?', com_ui_delete_conversation_confirm: 'This will delete', com_auth_error_login: 'Unable to login with the information provided. Please check your credentials and try again.', + com_auth_error_login_rl: + 'Too many login attempts from this IP in a short amount of time. Please try again later.', com_auth_no_account: 'Don\'t have an account?', com_auth_sign_up: 'Sign up', com_auth_sign_in: 'Sign in', @@ -96,6 +103,7 @@ export default { com_auth_to_try_again: 'to try again.', com_auth_submit_registration: 'Submit registration', com_auth_welcome_back: 'Welcome back', + com_endpoint_open_menu: 'Open Menu', com_endpoint_bing_enable_sydney: 'Enable Sydney', com_endpoint_bing_to_enable_sydney: 'To enable Sydney', com_endpoint_bing_jailbreak: 'Jailbreak', @@ -189,29 +197,30 @@ export default { com_endpoint_func_hover: 'Enable use of Plugins as OpenAI Functions', com_endpoint_skip_hover: 'Enable skipping the completion step, which reviews the final answer and generated steps', - com_endpoint_config_token: 'Config Token', - com_endpoint_config_token_for: 'Config Token for', - com_endpoint_config_token_name: 'Token Name', - com_endpoint_config_token_name_placeholder: 'Set token first', - com_endpoint_config_token_server: 'Your token will be sent to the server, but not saved.', - com_endpoint_config_token_import_json_key: 'Import Service Account JSON Key.', - com_endpoint_config_token_import_json_key_succesful: 'Import Service Account JSON Key.', - com_endpoint_config_token_import_json_key_invalid: + com_endpoint_config_key: 'Set API Key', + com_endpoint_config_key_for: 'Set API Key for', + com_endpoint_config_key_name: 'Key', + com_endpoint_config_value: 'Enter value for', + com_endpoint_config_key_name_placeholder: 'Set API key first', + com_endpoint_config_key_encryption: 'Your key will be encrypted and deleted at', + com_endpoint_config_key_expiry: 'the expiry time', + com_endpoint_config_key_import_json_key: 'Import Service Account JSON Key.', + com_endpoint_config_key_import_json_key_success: 'Successfully Imported Service Account JSON Key', + com_endpoint_config_key_import_json_key_invalid: 'Invalid Service Account JSON Key, Did you import the correct file?', - com_endpoint_config_token_get_edge_key: 'To get your Access token for Bing, login to', - com_endpoint_config_token_get_edge_key_dev_tool: + com_endpoint_config_key_get_edge_key: 'To get your Access token for Bing, login to', + com_endpoint_config_key_get_edge_key_dev_tool: 'Use dev tools or an extension while logged into the site to copy the content of the _U cookie. If this fails, follow these', - com_endpoint_config_token_edge_instructions: 'instructions', - com_endpoint_config_token_edge_full_token_string: 'to provide the full cookie strings.', - com_endpoint_config_token_chatgpt: - 'To get your Access token For ChatGPT \'Free Version\', login to', - com_endpoint_config_token_chatgpt_then_visit: 'then visit', - com_endpoint_config_token_chatgpt_copy_token: 'Copy access token.', - com_endpoint_config_token_google_need_to: 'You need to', - com_endpoint_config_token_google_vertex_ai: 'Enable Vertex AI', - com_endpoint_config_token_google_vertex_api: 'API on Google Cloud, then', - com_endpoint_config_token_google_service_account: 'Create a Service Account', - com_endpoint_config_token_google_vertex_api_role: + com_endpoint_config_key_edge_instructions: 'instructions', + com_endpoint_config_key_edge_full_key_string: 'to provide the full cookie strings.', + com_endpoint_config_key_chatgpt: 'To get your Access token For ChatGPT \'Free Version\', login to', + com_endpoint_config_key_chatgpt_then_visit: 'then visit', + com_endpoint_config_key_chatgpt_copy_token: 'Copy access token.', + com_endpoint_config_key_google_need_to: 'You need to', + com_endpoint_config_key_google_vertex_ai: 'Enable Vertex AI', + com_endpoint_config_key_google_vertex_api: 'API on Google Cloud, then', + com_endpoint_config_key_google_service_account: 'Create a Service Account', + com_endpoint_config_key_google_vertex_api_role: '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_export_filename: 'Filename', com_nav_export_filename_placeholder: 'Set the filename', @@ -240,6 +249,7 @@ export default { com_nav_settings: 'Settings', com_nav_search_placeholder: 'Search messages', com_nav_setting_general: 'General', + com_nav_setting_data: 'Data controls', com_nav_language: 'Language', com_nav_lang_english: 'English', com_nav_lang_chinese: '中文', diff --git a/client/src/localization/languages/Es.tsx b/client/src/localization/languages/Es.tsx index 82f787d82..a72c3de78 100644 --- a/client/src/localization/languages/Es.tsx +++ b/client/src/localization/languages/Es.tsx @@ -29,7 +29,8 @@ export default { com_ui_showing: 'Mostrando', com_ui_of: 'de', com_ui_entries: 'Entradas', - com_ui_pay_per_call: 'Todas las conversaciones de IA en un solo lugar. Pague por llamada y no por mes.', + com_ui_pay_per_call: + 'Todas las conversaciones de IA en un solo lugar. Pague por llamada y no por mes.', com_ui_delete: 'Eliminar', com_ui_delete_conversation: '¿Eliminar conversación?', com_ui_delete_conversation_confirm: 'Esto eliminará', @@ -84,6 +85,7 @@ export default { com_auth_to_try_again: 'para intentar nuevamente.', com_auth_submit_registration: 'Enviar registro', com_auth_welcome_back: 'Bienvenido(a) de vuelta', + com_endpoint_open_menu: 'Abrir Menú', com_endpoint_bing_enable_sydney: 'Habilitar Sydney', com_endpoint_bing_to_enable_sydney: 'Para habilitar Sydney', com_endpoint_bing_jailbreak: 'Jailbreak', diff --git a/client/src/localization/languages/Fr.tsx b/client/src/localization/languages/Fr.tsx index 66dcb724c..eb87111bd 100644 --- a/client/src/localization/languages/Fr.tsx +++ b/client/src/localization/languages/Fr.tsx @@ -83,6 +83,7 @@ export default { com_auth_to_try_again: 'pour réessayer.', com_auth_submit_registration: 'Soumettre l\'inscription', com_auth_welcome_back: 'Bienvenue à nouveau', + com_endpoint_open_menu: 'Ouvrir le menu', com_endpoint_bing_enable_sydney: 'Activer Sydney', com_endpoint_bing_to_enable_sydney: 'Pour activer Sydney', com_endpoint_bing_jailbreak: 'Jailbreak', diff --git a/client/src/localization/languages/It.tsx b/client/src/localization/languages/It.tsx index 85cbe6f08..8b47b7a07 100644 --- a/client/src/localization/languages/It.tsx +++ b/client/src/localization/languages/It.tsx @@ -96,6 +96,7 @@ export default { com_auth_to_try_again: 'per riprovare.', com_auth_submit_registration: 'Invia registrazione', com_auth_welcome_back: 'Bentornato', + com_endpoint_open_menu: 'Apri menu', com_endpoint_bing_enable_sydney: 'Abilita Sydney', com_endpoint_bing_to_enable_sydney: 'Per abilitare Sydney', com_endpoint_bing_jailbreak: 'Jailbreak', diff --git a/client/src/localization/languages/Pl.tsx b/client/src/localization/languages/Pl.tsx index 844841274..32c04b58f 100644 --- a/client/src/localization/languages/Pl.tsx +++ b/client/src/localization/languages/Pl.tsx @@ -80,6 +80,7 @@ export default { com_auth_to_try_again: 'aby spróbować ponownie.', com_auth_submit_registration: 'Zarejestruj się', com_auth_welcome_back: 'Witamy z powrotem', + com_endpoint_open_menu: 'Otwórz menu', com_endpoint_bing_enable_sydney: 'Aktywuj Sydney', com_endpoint_bing_to_enable_sydney: 'Aby aktywować Sydney', com_endpoint_bing_jailbreak: 'Odblokuj', diff --git a/client/src/localization/languages/Ru.tsx b/client/src/localization/languages/Ru.tsx index 4e375270f..94e940714 100644 --- a/client/src/localization/languages/Ru.tsx +++ b/client/src/localization/languages/Ru.tsx @@ -80,6 +80,7 @@ export default { com_auth_to_try_again: 'чтобы попробовать снова.', com_auth_submit_registration: 'Отправить регистрацию', com_auth_welcome_back: 'С возвращением', + com_endpoint_open_menu: 'Открыть меню', com_endpoint_bing_enable_sydney: 'Включить Сидней', com_endpoint_bing_to_enable_sydney: 'Чтобы включить Сидней', com_endpoint_bing_jailbreak: 'Jailbreak', diff --git a/client/src/localization/languages/Zh.tsx b/client/src/localization/languages/Zh.tsx index 1dd9d35ea..6e38e50ff 100644 --- a/client/src/localization/languages/Zh.tsx +++ b/client/src/localization/languages/Zh.tsx @@ -74,6 +74,7 @@ export default { com_auth_to_try_again: '再试一次.', com_auth_submit_registration: '注册提交', com_auth_welcome_back: '欢迎', + com_endpoint_open_menu: '打开菜单', com_endpoint_bing_enable_sydney: '启用 Sydney', com_endpoint_bing_to_enable_sydney: '启用 Sydney', com_endpoint_bing_jailbreak: '破解', diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 7c33e43a9..5328389e1 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -6,7 +6,6 @@ import text from './text'; import submission from './submission'; import search from './search'; import preset from './preset'; -import token from './token'; import lang from './language'; import optionSettings from './optionSettings'; @@ -19,7 +18,6 @@ export default { ...submission, ...search, ...preset, - ...token, ...lang, ...optionSettings, }; diff --git a/client/src/store/token.ts b/client/src/store/token.ts deleted file mode 100644 index a2fc9df60..000000000 --- a/client/src/store/token.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { atom, useSetRecoilState } from 'recoil'; - -const tokenRefreshHints = atom({ - key: 'tokenRefreshHints', - default: 1, -}); - -const useToken = (endpoint: string) => { - const setHints = useSetRecoilState(tokenRefreshHints); - const getToken = () => localStorage.getItem(`${endpoint}_token`); - const saveToken = (value: string) => { - localStorage.setItem(`${endpoint}_token`, value); - setHints((prev) => prev + 1); - }; - - return { token: getToken(), getToken, saveToken }; -}; - -export default { - useToken, -}; diff --git a/client/src/utils/getError.ts b/client/src/utils/getError.ts index 6967fc259..e41cc3951 100644 --- a/client/src/utils/getError.ts +++ b/client/src/utils/getError.ts @@ -18,10 +18,10 @@ const getError = (text: string) => { } else if (json.type === 'insufficient_quota') { return 'We apologize for any inconvenience caused. The default API key has reached its limit. To continue using this service, please set up your own API key. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.'; } else { - return `Oops! Something went wrong. Please try again in a few moments. Here's the specific error message we encountered: ${errorMessage}`; + return `Something went wrong. Here's the specific error message we encountered: ${errorMessage}`; } } else { - return `Oops! Something went wrong. Please try again in a few moments. Here's the specific error message we encountered: ${errorMessage}`; + return `Something went wrong. Here's the specific error message we encountered: ${errorMessage}`; } }; diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 9bdfc8a82..eb264d27b 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -30,7 +30,9 @@ module.exports = { }, colors: { gray: { + '20': '#ececf1', '50': '#f7f7f8', + '70': '#d1d5db', '100': '#d9d9e3', '200': '#d9d9e3', // Replacing .bg-gray-200 '300': '#c5c5d2', diff --git a/docs/features/plugins/make_your_own.md b/docs/features/plugins/make_your_own.md index 731cbfb20..565a7cf10 100644 --- a/docs/features/plugins/make_your_own.md +++ b/docs/features/plugins/make_your_own.md @@ -201,7 +201,7 @@ Here are a few customConstructors, which have varying initializations ## Step 6: Export your Plugin into index.js -##Find the `index.js` under `api/app/clients/tools`. You need to put your plugin into the `module.exports`, to make it compile, you will also need to declare your plugin as `consts`: +Find the `index.js` under `api/app/clients/tools`. You need to put your plugin into the `module.exports`, to make it compile, you will also need to declare your plugin as `consts`: ```js const StructuredSD = require('./structured/StableDiffusion'); @@ -243,12 +243,12 @@ module.exports = { }, ``` - Each of the fields of the "plugin" object are important. Follow this format strictly. If your plugin requires authentication, you will add those details under `authConfig` as an array since there could be multiple authentication variables. See the Calculator plugin for an example of one that doesn't require authentication, where the authConfig is an empty array (an array is always required). - - **Note:** as mentioned earlier, the `pluginKey` matches the class `name` of the Tool class you made. - **Note:** the `authField` prop must match the process.env variable name - - Here is an example of a plugin with more than one credential variable +Each of the fields of the "plugin" object are important. Follow this format strictly. If your plugin requires authentication, you will add those details under `authConfig` as an array since there could be multiple authentication variables. See the Calculator plugin for an example of one that doesn't require authentication, where the authConfig is an empty array (an array is always required). + +**Note:** as mentioned earlier, the `pluginKey` matches the class `name` of the Tool class you made. +**Note:** the `authField` prop must match the process.env variable name + +Here is an example of a plugin with more than one credential variable ```json [ diff --git a/e2e/.env.test.example b/e2e/.env.test.example new file mode 100644 index 000000000..e7a3fc48e --- /dev/null +++ b/e2e/.env.test.example @@ -0,0 +1,9 @@ +# Test database. You can use your actual MONGO_URI if you don't mind it potentially including test data. +MONGO_URI=mongodb://127.0.0.1:27017/chatgpt-jest + +# Credential encryption/decryption for testing +CREDS_KEY=c3301ad2f69681295e022fb135e92787afb6ecfeaa012a10f8bb4ddf6b669e6d +CREDS_IV=cd02538f4be2fa37aba9420b5924389f + +# For testing the ChatAgent +OPENAI_API_KEY=your-api-key diff --git a/e2e/specs/keys.spec.ts b/e2e/specs/keys.spec.ts new file mode 100644 index 000000000..8e30ef5fa --- /dev/null +++ b/e2e/specs/keys.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +const enterTestKey = async (page: Page, endpoint: string) => { + await page.getByTestId('new-conversation-menu').click(); + await page.getByTestId(`endpoint-item-${endpoint}`).hover({ force: true }); + await page.getByRole('button', { name: 'Set API Key' }).click(); + await page.getByTestId(`input-${endpoint}`).fill('test'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByTestId(`endpoint-item-${endpoint}`).click(); +}; + +test.describe('Key suite', () => { + // npx playwright test --config=e2e/playwright.config.local.ts --headed e2e/specs/keys.spec.ts + test('Test Setting and Revoking Keys', async ({ page }) => { + await page.goto('http://localhost:3080/'); + const endpoint = 'chatGPTBrowser'; + + const newTopicButton = page.getByTestId('new-conversation-menu'); + await newTopicButton.click(); + + const endpointItem = page.getByTestId(`endpoint-item-${endpoint}`); + await endpointItem.click(); + + let setKeyButton = page.getByRole('button', { name: 'Set API key first' }); + + expect(setKeyButton.count()).toBeTruthy(); + + await enterTestKey(page, endpoint); + + const submitButton = page.getByTestId('submit-button'); + + expect(submitButton.count()).toBeTruthy(); + + await newTopicButton.click(); + + await endpointItem.hover({ force: true }); + + await page.getByRole('button', { name: 'Set API Key' }).click(); + await page.getByRole('button', { name: 'Revoke' }).click(); + await page.getByRole('button', { name: 'Confirm Action' }).click(); + await page + .locator('div') + .filter({ hasText: /^Revoke$/ }) + .nth(1) + .click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + setKeyButton = page.getByRole('button', { name: 'Set API key first' }); + expect(setKeyButton.count()).toBeTruthy(); + }); + + test('Test Setting and Revoking Keys from Settings', async ({ page }) => { + await page.goto('http://localhost:3080/'); + const endpoint = 'bingAI'; + + const newTopicButton = page.getByTestId('new-conversation-menu'); + await newTopicButton.click(); + + const endpointItem = page.getByTestId(`endpoint-item-${endpoint}`); + await endpointItem.click(); + + let setKeyButton = page.getByRole('button', { name: 'Set API key first' }); + + expect(setKeyButton.count()).toBeTruthy(); + + await enterTestKey(page, endpoint); + + const submitButton = page.getByTestId('submit-button'); + + expect(submitButton.count()).toBeTruthy(); + + await page.getByRole('button', { name: 'test' }).click(); + await page.getByText('Settings').click(); + await page.getByRole('tab', { name: 'Data controls' }).click(); + await page.getByRole('button', { name: 'Revoke' }).click(); + await page.getByRole('button', { name: 'Confirm Action' }).click(); + + const revokeButton = page.getByRole('button', { name: 'Revoke' }); + expect(revokeButton.count()).toBeTruthy(); + + await page.getByRole('button', { name: 'Close' }).click(); + + setKeyButton = page.getByRole('button', { name: 'Set API key first' }); + expect(setKeyButton.count()).toBeTruthy(); + }); +}); diff --git a/e2e/specs/popup.spec.ts b/e2e/specs/popup.spec.ts index a99022801..edf87ad28 100644 --- a/e2e/specs/popup.spec.ts +++ b/e2e/specs/popup.spec.ts @@ -3,13 +3,13 @@ import { expect, test } from '@playwright/test'; test.describe('Endpoints Presets suite', () => { test('Endpoints Suite', async ({ page }) => { await page.goto('http://localhost:3080/'); - await page.getByRole('button', { name: 'New Topic' }).click(); + await page.getByTestId('new-conversation-menu').click(); // includes the icon + endpoint names in obj property const endpointItem = page.getByRole('menuitemradio', { name: 'ChatGPT OpenAI' }); await endpointItem.click(); - await page.getByRole('button', { name: 'New Topic' }).click(); + await page.getByTestId('new-conversation-menu').click(); // Check if the active class is set on the selected endpoint expect(await endpointItem.getAttribute('class')).toContain('active'); }); diff --git a/package-lock.json b/package-lock.json index fb1ff38d4..f086f1aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,8 @@ "dotenv": "^16.0.3", "eslint": "^8.41.0", "express": "^4.18.2", + "express-mongo-sanitize": "^2.2.0", + "express-rate-limit": "^6.9.0", "express-session": "^1.17.3", "googleapis": "^118.0.0", "handlebars": "^4.7.7", @@ -91,7 +93,7 @@ }, "devDependencies": { "jest": "^29.5.0", - "nodemon": "^2.0.20", + "nodemon": "^3.0.1", "path": "^0.12.7", "supertest": "^6.3.3" } @@ -516,6 +518,7 @@ "@radix-ui/react-slider": "^1.1.1", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3", + "@radix-ui/react-tooltip": "^1.0.6", "@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^4.28.0", "@zattoo/use-double-click": "1.2.0", @@ -6614,6 +6617,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.6.tgz", + "integrity": "sha512-DmNFOiwEc2UDigsYj6clJENma58OelxD24O4IODoZ+3sQc3Zb+L8w1EP+y9laTuKCLAysPw4fD6/v0j4KNV8rg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.2", + "@radix-ui/react-portal": "1.0.3", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", @@ -6737,6 +6774,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", @@ -12698,6 +12758,25 @@ "node": ">= 0.10.0" } }, + "node_modules/express-mongo-sanitize": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/express-mongo-sanitize/-/express-mongo-sanitize-2.2.0.tgz", + "integrity": "sha512-PZBs5nwhD6ek9ZuP+W2xmpvcrHwXZxD5GdieX2dsjPbAbH4azOkrHbycBud2QRU+YQF1CT+pki/lZGedHgo/dQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/express-rate-limit": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.9.0.tgz", + "integrity": "sha512-AnISR3V8qy4gpKM62/TzYdoFO9NV84fBx0POXzTryHU/qGUJBWuVGd+JhbvtVmKBv37t8/afmqdnv16xWoQxag==", + "engines": { + "node": ">= 14.0.0" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, "node_modules/express-session": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", @@ -19593,9 +19672,9 @@ } }, "node_modules/nodemon": { - "version": "2.0.22", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", - "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", + "integrity": "sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==", "dev": true, "dependencies": { "chokidar": "^3.5.2", @@ -19603,8 +19682,8 @@ "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" @@ -19613,7 +19692,7 @@ "nodemon": "bin/nodemon.js" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" }, "funding": { "type": "opencollective", @@ -19629,15 +19708,6 @@ "ms": "^2.1.1" } }, - "node_modules/nodemon/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, "node_modules/nopt": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", @@ -23573,24 +23643,15 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, "node_modules/simple-update-notifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", - "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "dependencies": { - "semver": "~7.0.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + "node": ">=10" } }, "node_modules/sisteransi": { diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index a84a57e17..eea302867 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -1,95 +1,59 @@ -export const user = () => { - return '/api/user'; -}; +export const user = () => '/api/user'; -export const userPlugins = () => { - return '/api/user/plugins'; -}; +export const userPlugins = () => '/api/user/plugins'; -export const messages = (conversationId: string, messageId?: string) => { - return `/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`; -}; +export const messages = (conversationId: string, messageId?: string) => + `/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`; -export const abortRequest = (endpoint: string) => { - return `/api/ask/${endpoint}/abort`; -}; +const keysEndpoint = '/api/keys'; -export const conversations = (pageNumber: string) => { - return `/api/convos?pageNumber=${pageNumber}`; -}; +export const keys = () => keysEndpoint; -export const conversationById = (id: string) => { - return `/api/convos/${id}`; -}; +export const userKeyQuery = (name: string) => `${keysEndpoint}?name=${name}`; -export const updateConversation = () => { - return '/api/convos/update'; -}; +export const revokeUserKey = (name: string) => `${keysEndpoint}/${name}`; -export const deleteConversation = () => { - return '/api/convos/clear'; -}; +export const revokeAllUserKeys = () => `${keysEndpoint}?all=true`; -export const search = (q: string, pageNumber: string) => { - return `/api/search?q=${q}&pageNumber=${pageNumber}`; -}; +export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`; -export const searchEnabled = () => { - return '/api/search/enable'; -}; +export const conversations = (pageNumber: string) => `/api/convos?pageNumber=${pageNumber}`; -export const presets = () => { - return '/api/presets'; -}; +export const conversationById = (id: string) => `/api/convos/${id}`; -export const deletePreset = () => { - return '/api/presets/delete'; -}; +export const updateConversation = () => '/api/convos/update'; -export const aiEndpoints = () => { - return '/api/endpoints'; -}; +export const deleteConversation = () => '/api/convos/clear'; -export const tokenizer = () => { - return '/api/tokenizer'; -}; +export const search = (q: string, pageNumber: string) => + `/api/search?q=${q}&pageNumber=${pageNumber}`; -export const login = () => { - return '/api/auth/login'; -}; +export const searchEnabled = () => '/api/search/enable'; -export const logout = () => { - return '/api/auth/logout'; -}; +export const presets = () => '/api/presets'; -export const register = () => { - return '/api/auth/register'; -}; +export const deletePreset = () => '/api/presets/delete'; -export const loginFacebook = () => { - return '/api/auth/facebook'; -}; +export const aiEndpoints = () => '/api/endpoints'; -export const loginGoogle = () => { - return '/api/auth/google'; -}; +export const tokenizer = () => '/api/tokenizer'; -export const refreshToken = () => { - return '/api/auth/refresh'; -}; +export const login = () => '/api/auth/login'; -export const requestPasswordReset = () => { - return '/api/auth/requestPasswordReset'; -}; +export const logout = () => '/api/auth/logout'; -export const resetPassword = () => { - return '/api/auth/resetPassword'; -}; +export const register = () => '/api/auth/register'; -export const plugins = () => { - return '/api/plugins'; -}; +export const loginFacebook = () => '/api/auth/facebook'; -export const config = () => { - return '/api/config'; -}; +export const loginGoogle = () => '/api/auth/google'; + +export const refreshToken = () => '/api/auth/refresh'; + +export const requestPasswordReset = () => '/api/auth/requestPasswordReset'; + +export const resetPassword = () => '/api/auth/resetPassword'; + +export const plugins = () => '/api/plugins'; + +export const config = () => '/api/config'; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 24d7822d1..76eba9c68 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -24,6 +24,14 @@ export function clearAllConversations(): Promise { return request.post(endpoints.deleteConversation(), { arg: {} }); } +export function revokeUserKey(name: string): Promise { + return request.delete(endpoints.revokeUserKey(name)); +} + +export function revokeAllUserKeys(): Promise { + return request.delete(endpoints.revokeAllUserKeys()); +} + export function getMessagesByConvoId(conversationId: string): Promise { return request.get(endpoints.messages(conversationId)); } @@ -47,6 +55,15 @@ export function updateMessage(payload: t.TUpdateMessageRequest): Promise { return request.get(endpoints.presets()); } @@ -98,9 +115,10 @@ export const register = (payload: t.TRegisterUser) => { return request.post(endpoints.register(), payload); }; -export const refreshToken = () => { - return request.post(endpoints.refreshToken()); -}; +export const refreshToken = () => request.post(endpoints.refreshToken()); + +export const userKeyQuery = (name: string): Promise => + request.get(endpoints.userKeyQuery(name)); export const getLoginGoogle = () => { return request.get(endpoints.loginGoogle()); diff --git a/packages/data-provider/src/react-query-service.ts b/packages/data-provider/src/react-query-service.ts index d75849c47..bb922ee87 100644 --- a/packages/data-provider/src/react-query-service.ts +++ b/packages/data-provider/src/react-query-service.ts @@ -16,6 +16,7 @@ export enum QueryKeys { conversation = 'conversation', searchEnabled = 'searchEnabled', user = 'user', + name = 'name', // user key name endpoints = 'endpoints', presets = 'presets', searchResults = 'searchResults', @@ -121,6 +122,20 @@ export const useUpdateMessageMutation = ( }); }; +export const useUpdateUserKeysMutation = (): UseMutationResult< + t.TUser, + unknown, + t.TUpdateUserKeyRequest, + unknown +> => { + const queryClient = useQueryClient(); + return useMutation((payload: t.TUpdateUserKeyRequest) => dataService.updateUserKey(payload), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.name]); + }, + }); +}; + export const useDeleteConversationMutation = ( id?: string, ): UseMutationResult< @@ -150,6 +165,24 @@ export const useClearConversationsMutation = (): UseMutationResult => { }); }; +export const useRevokeUserKeyMutation = (name: string): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation(() => dataService.revokeUserKey(name), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.name]); + }, + }); +}; + +export const useRevokeAllUserKeysMutation = (): UseMutationResult => { + const queryClient = useQueryClient(); + return useMutation(() => dataService.revokeAllUserKeys(), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.name]); + }, + }); +}; + export const useGetConversationsQuery = ( pageNumber: string, config?: UseQueryOptions, @@ -315,6 +348,28 @@ export const useRefreshTokenMutation = (): UseMutationResult< return useMutation(() => dataService.refreshToken(), {}); }; +export const useUserKeyQuery = ( + name: string, + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.name, name], + () => { + if (!name) { + return Promise.resolve({ expiresAt: '' }); + } + return dataService.userKeyQuery(name); + }, + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + ...config, + }, + ); +}; + export const useRequestPasswordResetMutation = (): UseMutationResult< t.TRequestPasswordResetResponse, unknown, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 122ea0438..b8de2150d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -399,7 +399,7 @@ export type TEndpointOption = { chatGptLabel?: string | null; modelLabel?: string | null; jailbreak?: boolean; - token?: string | null; + key?: string | null; }; export const getResponseSender = (endpointOption: TEndpointOption): string => { diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index f70fadc51..82442c412 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -1,4 +1,7 @@ import type { TResPlugin, TMessage, TConversation, TEndpointOption } from './schemas'; +import type { UseMutationResult } from '@tanstack/react-query'; + +export type TMutation = UseMutationResult; export * from './schemas'; @@ -69,6 +72,12 @@ export type TUpdateMessageRequest = { text: string; }; +export type TUpdateUserKeyRequest = { + name: string; + value: string; + expiresAt: string; +}; + export type TUpdateConversationRequest = { conversationId: string; title: string; @@ -177,6 +186,10 @@ export type TRefreshTokenResponse = { user: TUser; }; +export type TCheckUserKeyResponse = { + expiresAt: string; +}; + export type TRequestPasswordResetResponse = { link?: string; message?: string;