From 4ca43fb53da72256c79e69f8cd414bd1ee75bdf7 Mon Sep 17 00:00:00 2001 From: Danny Avila <110412045+danny-avila@users.noreply.github.com> Date: Wed, 6 Sep 2023 10:46:27 -0400 Subject: [PATCH] refactor: Encrypt & Expire User Provided Keys, feat: Rate Limiting (#874) * docs: make_your_own.md formatting fix for mkdocs * feat: add express-mongo-sanitize feat: add login/registration rate limiting * chore: remove unnecessary console log * wip: remove token handling from localStorage to encrypted DB solution * refactor: minor change to UserService * fix mongo query and add keys route to server * fix backend controllers and simplify schema/crud * refactor: rename token to key to separate from access/refresh tokens, setTokenDialog -> setKeyDialog * refactor(schemas): TEndpointOption token -> key * refactor(api): use new encrypted key retrieval system * fix(SetKeyDialog): fix key prop error * fix(abortMiddleware): pass random UUID if messageId is not generated yet for proper error display on frontend * fix(getUserKey): wrong prop passed in arg, adds error handling * fix: prevent message without conversationId from saving to DB, prevents branching on the frontend to a new top-level branch * refactor: change wording of multiple display messages * refactor(checkExpiry -> checkUserKeyExpiry): move to UserService file * fix: type imports from common * refactor(SubmitButton): convert to TS * refactor(key.ts): change localStorage map key name * refactor: add new custom tailwind classes to better match openAI colors * chore: remove unnecessary warning and catch ScreenShot error * refactor: move userKey frontend logic to hooks and remove use of localStorage and instead query the DB * refactor: invalidate correct query key, memoize userKey hook, conditionally render SetKeyDialog to avoid unnecessary calls, refactor SubmitButton props and useEffect for showing 'provide key first' * fix(SetKeyDialog): use enum-like object for expiry values feat(Dropdown): add optionsClassName to dynamically change dropdown options container classes * fix: handle edge case where user had provided a key but the server changes to env variable for keys * refactor(OpenAI/titleConvo): move titling to client to retain authorized credentials in message lifecycle for titling * fix(azure): handle user_provided keys correctly for azure * feat: send user Id to OpenAI to differentiate users in completion requests * refactor(OpenAI/titleConvo): adding tokens helps minimize LLM from using the language in title response * feat: add delete endpoint for keys * chore: remove throttling of title * feat: add 'Data controls' to Settings, add 'Revoke' keys feature in Key Dialog and Data controls * refactor: reorganize PluginsClient files in langchain format * feat: use langchain for titling convos * chore: cleanup titling convo, with fallback to original method, escape braces, use only snippet for language detection * refactor: move helper functions to appropriate langchain folders for reusability * fix: userProvidesKey handling for gptPlugins * fix: frontend handling of plugins key * chore: cleanup logging and ts-ignore SSE * fix: forwardRef misuse in DangerButton * fix(GoogleConfig/FileUpload): localize errors and simplify validation with zod * fix: cleanup google logging and fix user provided key handling * chore: remove titling from google * chore: removing logging from browser endpoint * wip: fix menu flicker * feat: useLocalStorage hook * feat: add Tooltip for UI * refactor(EndpointMenu): utilize Tooltip and useLocalStorage, remove old 'New Chat' slide-over * fix(e2e): use testId for endpoint menu trigger * chore: final touches to EndpointMenu before future refactor to declutter component * refactor(localization): change select endpoint to open menu and add translations * chore: add final prop to error message response * ci: minor edits to facilitate testing * ci: new e2e test which tests for new key setting/revoking features --- .env.example | 7 + api/app/bingai.js | 19 +- api/app/chatgpt-browser.js | 23 +- api/app/clients/BaseClient.js | 12 +- api/app/clients/GoogleClient.js | 6 +- api/app/clients/OpenAIClient.js | 64 +++- api/app/clients/PluginsClient.js | 13 +- api/app/clients/chains/index.js | 5 + api/app/clients/chains/runTitleChain.js | 43 +++ .../{agents/methods => llm}/createLLM.js | 0 api/app/clients/llm/index.js | 5 + .../methods => output_parsers}/addImages.js | 0 .../clients/output_parsers/handleInputs.js | 38 +++ .../handleOutputs.js | 6 +- .../methods => output_parsers}/index.js | 4 +- api/app/clients/prompts/index.js | 9 + .../{refinePrompt.js => refinePrompts.js} | 0 api/app/clients/prompts/titlePrompts.js | 33 +++ api/app/index.js | 2 - api/app/titleConvo.js | 57 ---- api/models/Message.js | 3 + api/models/User.js | 86 +----- api/models/index.js | 5 + api/models/schema/keySchema.js | 25 ++ api/models/schema/userSchema.js | 88 ++++++ api/package.json | 4 +- api/server/index.js | 5 +- api/server/middleware/abortMiddleware.js | 4 +- api/server/middleware/index.js | 4 + api/server/middleware/loginLimiter.js | 12 + api/server/middleware/registerLimiter.js | 12 + api/server/routes/ask/anthropic.js | 4 +- api/server/routes/ask/askChatGPTBrowser.js | 2 +- api/server/routes/ask/bingAI.js | 5 +- api/server/routes/ask/google.js | 30 +- api/server/routes/ask/gptPlugins.js | 20 +- api/server/routes/ask/openAI.js | 19 +- api/server/routes/auth.js | 12 +- api/server/routes/edit/anthropic.js | 4 +- api/server/routes/edit/gptPlugins.js | 4 +- api/server/routes/edit/openAI.js | 4 +- api/server/routes/endpoints.js | 10 +- .../endpoints/anthropic/initializeClient.js | 18 +- .../endpoints/gptPlugins/initializeClient.js | 37 ++- .../routes/endpoints/openAI/addTitle.js | 23 +- .../endpoints/openAI/initializeClient.js | 27 +- api/server/routes/index.js | 2 + api/server/routes/keys.js | 35 +++ api/server/routes/oauth.js | 6 + api/server/services/UserService.js | 65 ++++- client/package.json | 1 + client/src/common/types.ts | 28 +- client/src/components/Auth/Login.tsx | 4 +- .../components/Endpoints/EditPresetDialog.tsx | 2 +- .../Endpoints/Settings/AgentSettings.tsx | 3 +- .../Endpoints/Settings/Anthropic.tsx | 3 +- .../components/Endpoints/Settings/Google.tsx | 3 +- .../components/Endpoints/Settings/OpenAI.tsx | 3 +- .../components/Endpoints/Settings/Plugins.tsx | 3 +- .../Input/EndpointMenu/EndpointItem.tsx | 20 +- .../Input/EndpointMenu/EndpointMenu.jsx | 274 +++++++++--------- .../Input/EndpointMenu/FileUpload.tsx | 17 +- .../Input/SetKeyDialog/GoogleConfig.tsx | 35 +++ .../HelpText.tsx | 24 +- .../InputWithLabel.tsx | 5 +- .../OpenAIConfig.tsx | 58 ++-- .../Input/SetKeyDialog/OtherConfig.tsx | 18 ++ .../Input/SetKeyDialog/SetKeyDialog.tsx | 103 +++++++ .../components/Input/SetKeyDialog/index.ts | 1 + .../Input/SetTokenDialog/GoogleConfig.tsx | 52 ---- .../Input/SetTokenDialog/OtherConfig.tsx | 22 -- .../Input/SetTokenDialog/SetTokenDialog.tsx | 56 ---- .../components/Input/SetTokenDialog/index.ts | 1 - .../{SubmitButton.jsx => SubmitButton.tsx} | 61 ++-- client/src/components/Input/TextChat.jsx | 4 +- .../Messages/Content/MessageContent.tsx | 2 +- .../components/Messages/Content/Plugin.tsx | 2 +- client/src/components/Messages/Message.tsx | 15 +- .../Nav/ExportConversation/ExportModel.jsx | 8 +- client/src/components/Nav/Settings.jsx | 88 ------ client/src/components/Nav/Settings.tsx | 69 +++++ .../Nav/SettingsTabs/DangerButton.tsx | 70 +++++ .../src/components/Nav/SettingsTabs/Data.tsx | 76 +++++ .../components/Nav/SettingsTabs/General.tsx | 94 +++--- .../src/components/Nav/SettingsTabs/index.ts | 2 + client/src/components/svg/CogIcon.tsx | 9 +- client/src/components/svg/DataIcon.tsx | 18 ++ client/src/components/svg/index.ts | 1 + client/src/components/ui/Dropdown.jsx | 21 +- client/src/components/ui/DropdownMenu.tsx | 3 +- .../src/components/ui/MultiSelectDropDown.tsx | 2 +- client/src/components/ui/SelectDropDown.tsx | 2 +- client/src/components/ui/Tooltip.tsx | 45 +++ client/src/components/ui/index.ts | 1 + client/src/hooks/index.ts | 3 + client/src/hooks/useLocalStorage.tsx | 53 ++++ client/src/hooks/useMessageHandler.ts | 6 +- client/src/hooks/useServerStream.ts | 1 + client/src/hooks/useUserKey.ts | 58 ++++ client/src/localization/languages/Br.tsx | 1 + client/src/localization/languages/De.tsx | 1 + client/src/localization/languages/Eng.tsx | 52 ++-- client/src/localization/languages/Es.tsx | 4 +- client/src/localization/languages/Fr.tsx | 1 + client/src/localization/languages/It.tsx | 1 + client/src/localization/languages/Pl.tsx | 1 + client/src/localization/languages/Ru.tsx | 1 + client/src/localization/languages/Zh.tsx | 1 + client/src/store/index.ts | 2 - client/src/store/token.ts | 21 -- client/src/utils/getError.ts | 4 +- client/tailwind.config.cjs | 2 + docs/features/plugins/make_your_own.md | 14 +- e2e/.env.test.example | 9 + e2e/specs/keys.spec.ts | 86 ++++++ e2e/specs/popup.spec.ts | 4 +- package-lock.json | 121 ++++++-- packages/data-provider/src/api-endpoints.ts | 108 +++---- packages/data-provider/src/data-service.ts | 24 +- .../data-provider/src/react-query-service.ts | 55 ++++ packages/data-provider/src/schemas.ts | 2 +- packages/data-provider/src/types.ts | 13 + 122 files changed, 1933 insertions(+), 966 deletions(-) create mode 100644 api/app/clients/chains/index.js create mode 100644 api/app/clients/chains/runTitleChain.js rename api/app/clients/{agents/methods => llm}/createLLM.js (100%) create mode 100644 api/app/clients/llm/index.js rename api/app/clients/{agents/methods => output_parsers}/addImages.js (100%) create mode 100644 api/app/clients/output_parsers/handleInputs.js rename api/app/clients/{agents/methods => output_parsers}/handleOutputs.js (96%) rename api/app/clients/{agents/methods => output_parsers}/index.js (68%) create mode 100644 api/app/clients/prompts/index.js rename api/app/clients/prompts/{refinePrompt.js => refinePrompts.js} (100%) create mode 100644 api/app/clients/prompts/titlePrompts.js delete mode 100644 api/app/titleConvo.js create mode 100644 api/models/schema/keySchema.js create mode 100644 api/models/schema/userSchema.js create mode 100644 api/server/middleware/loginLimiter.js create mode 100644 api/server/middleware/registerLimiter.js create mode 100644 api/server/routes/keys.js create mode 100644 client/src/components/Input/SetKeyDialog/GoogleConfig.tsx rename client/src/components/Input/{SetTokenDialog => SetKeyDialog}/HelpText.tsx (70%) rename client/src/components/Input/{SetTokenDialog => SetKeyDialog}/InputWithLabel.tsx (86%) rename client/src/components/Input/{SetTokenDialog => SetKeyDialog}/OpenAIConfig.tsx (69%) create mode 100644 client/src/components/Input/SetKeyDialog/OtherConfig.tsx create mode 100644 client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx create mode 100644 client/src/components/Input/SetKeyDialog/index.ts delete mode 100644 client/src/components/Input/SetTokenDialog/GoogleConfig.tsx delete mode 100644 client/src/components/Input/SetTokenDialog/OtherConfig.tsx delete mode 100644 client/src/components/Input/SetTokenDialog/SetTokenDialog.tsx delete mode 100644 client/src/components/Input/SetTokenDialog/index.ts rename client/src/components/Input/{SubmitButton.jsx => SubmitButton.tsx} (68%) delete mode 100644 client/src/components/Nav/Settings.jsx create mode 100644 client/src/components/Nav/Settings.tsx create mode 100644 client/src/components/Nav/SettingsTabs/DangerButton.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Data.tsx create mode 100644 client/src/components/svg/DataIcon.tsx create mode 100644 client/src/components/ui/Tooltip.tsx create mode 100644 client/src/hooks/useLocalStorage.tsx create mode 100644 client/src/hooks/useUserKey.ts delete mode 100644 client/src/store/token.ts create mode 100644 e2e/.env.test.example create mode 100644 e2e/specs/keys.spec.ts diff --git a/.env.example b/.env.example index 79b712ccb2..4190041257 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 97f47ec921..e178241c17 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 cf98194415..514bb22c22 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 ec4344bb46..4df364b751 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 2fad6ca97f..dee0ab829d 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 87b25b1226..e3d15e66e2 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 5dc8fa4dd1..36a3dabb42 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 0000000000..259d01d56d --- /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 0000000000..1178f15063 --- /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 0000000000..4d97bfb2a2 --- /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 0000000000..1a193e058f --- /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 cda50e496b..b25eaaad80 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 938210201f..fdc6693d79 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 0000000000..8cf3bb468f --- /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 0000000000..573aff5411 --- /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 fe1462331e..be9c5e9ad0 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 65ef44d28d..0000000000 --- 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 98ee174ccc..d85f4f068b 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 5e1d035def..74cba37306 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 a42d2c177f..8f2a03c8db 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 0000000000..84b16b8a67 --- /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 0000000000..80e635fc60 --- /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 6f0b6f8a00..626149110c 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 03dfda1208..1f43918d29 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 7eedc0ce47..4720ccddbe 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 a5c7f3c969..eb1f538704 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 0000000000..283e3b1b82 --- /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 0000000000..9bd3bc91f4 --- /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 beca07b85a..3517e928b8 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 088e1da4a3..b590314f90 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 4eedb0df45..10bf55442e 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 17775e2f38..aa77c3129f 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 e0d60009c1..890228cefc 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 be236956af..5e5f445080 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 ff2e7cab0e..ed7d1a68c1 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 10a291ea78..6f2bfc58e1 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 89886ea822..8f635ec275 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 1f15d25d06..fe1c5e2950 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 bee04998e1..dc5533c8b5 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 eab0487f95..deed53ba46 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 6035a6f5a4..428f612a0e 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 c3f6c2ad2a..87b846ba00 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 4e4910c5b0..df09a3f200 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 c78af3fcff..2d13158395 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 0000000000..cb8a4a5d92 --- /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 ea5837427e..069ece7cb7 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 ba037be8e0..c3a25f3b92 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 919d274f5f..fece663540 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 34423ee7ec..63635d84b7 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 025051da5c..6e2616c24c 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 29140f7f1c..2e11d7851a 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 2818abb7a8..1885465e06 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 e9348f7766..156c610368 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 14e85cda9e..07507f86c2 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 d6090bb0ab..4740dfe178 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 3562abf24d..38dbe89f95 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 38e822a9f5..fc68a01b71 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 dbf0cab4f2..3e4493308c 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 ef76866c5b..aeed31c51b 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 0000000000..04e9f83eb4 --- /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 4a5b9081da..0000000000 --- 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 32de116070..0000000000 --- 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 1d6055e9fb..0000000000 --- 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 ee85de11ce..0000000000 --- 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 d5c476beca..ae75386a41 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 53f4efa357..939c90f3b3 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 4a0ac8736b..969601ff86 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 2f4c2289c6..b4967aac25 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 d31a8cb39a..5573b0a2cd 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 262c4275d6..2bdc9940dd 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 31b90e39df..6686cbaf59 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 55f19eebe4..746f44cddd 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 0000000000..87ee5d4618 --- /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 4c491f03f1..f031c250aa 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 32d339357f..054283376e 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 0000000000..d718926fa3 --- /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 ec22c3eee4..2133d7a2f5 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 0354df3d21..62813e1bbb 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 047e617b24..305f81b672 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 82f787d82c..a72c3de78d 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 66dcb724cd..eb87111bd0 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 85cbe6f086..8b47b7a07e 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 844841274d..32c04b58fc 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 4e375270f6..94e940714e 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 1dd9d35ea0..6e38e50ffc 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 7c33e43a99..5328389e1e 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 a2fc9df602..0000000000 --- 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 6967fc2592..e41cc39519 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 9bdfc8a828..eb264d27b7 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 731cbfb203..565a7cf10e 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 0000000000..e7a3fc48e9 --- /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 0000000000..8e30ef5fa7 --- /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 a990228018..edf87ad28a 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 fb1ff38d47..f086f1aae1 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 a84a57e17e..eea302867a 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 24d7822d13..76eba9c687 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 d75849c471..bb922ee87b 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 122ea0438b..b8de2150dc 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 f70fadc51c..82442c4120 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;