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 | 270 +++++++++--------- .../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 | 92 +++--- .../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, 1930 insertions(+), 963 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 79b712cc..41900412 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 97f47ec9..e178241c 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 cf981944..514bb22c 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 ec4344bb..4df364b7 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 2fad6ca9..dee0ab82 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 87b25b12..e3d15e66 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 5dc8fa4d..36a3dabb 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 00000000..259d01d5 --- /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 00000000..1178f150 --- /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 00000000..4d97bfb2 --- /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 00000000..1a193e05 --- /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 cda50e49..b25eaaad 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 93821020..fdc6693d 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 00000000..8cf3bb46 --- /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 00000000..573aff54 --- /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 fe146233..be9c5e9a 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 65ef44d2..00000000 --- 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 98ee174c..d85f4f06 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 5e1d035d..74cba373 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 a42d2c17..8f2a03c8 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 00000000..84b16b8a --- /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 00000000..80e635fc --- /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 6f0b6f8a..62614911 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 03dfda12..1f43918d 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 7eedc0ce..4720ccdd 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 a5c7f3c9..eb1f5387 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 00000000..283e3b1b --- /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 00000000..9bd3bc91 --- /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 beca07b8..3517e928 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 088e1da4..b590314f 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 4eedb0df..10bf5544 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 17775e2f..aa77c312 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 e0d60009..890228ce 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 be236956..5e5f4450 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 ff2e7cab..ed7d1a68 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 10a291ea..6f2bfc58 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 89886ea8..8f635ec2 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 1f15d25d..fe1c5e29 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 bee04998..dc5533c8 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 eab0487f..deed53ba 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 6035a6f5..428f612a 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 c3f6c2ad..87b846ba 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 4e4910c5..df09a3f2 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 c78af3fc..2d131583 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 00000000..cb8a4a5d --- /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 ea583742..069ece7c 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 ba037be8..c3a25f3b 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 919d274f..fece6635 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 34423ee7..63635d84 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 025051da..6e2616c2 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 29140f7f..2e11d785 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 2818abb7..1885465e 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 e9348f77..156c6103 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 14e85cda..07507f86 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 d6090bb0..4740dfe1 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 3562abf2..38dbe89f 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 38e822a9..fc68a01b 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 dbf0cab4..3e449330 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')} - - ))} - + + + + + + + + + + + {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')} - - ))} - - - - -
+ {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 ef76866c..aeed31c5 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 00000000..04e9f83e --- /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 4a5b9081..00000000 --- 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 32de1160..00000000 --- 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 1d6055e9..00000000 --- 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 ee85de11..00000000 --- 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 d5c476be..ae75386a 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 clickHandler = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + submitMessage(); + }, + [submitMessage], + ); - const setToken = () => { - setSetTokenDialogOpen(true); - }; + 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 53f4efa3..939c90f3 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 4a0ac873..969601ff 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 2f4c2289..b4967aac 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 d31a8cb3..5573b0a2 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 262c4275..2bdc9940 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 31b90e39..6686cbaf 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 55f19eeb..746f44cd 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 00000000..87ee5d46 --- /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 4c491f03..f031c250 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 32d33935..05428337 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 00000000..d718926f --- /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 ec22c3ee..2133d7a2 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 0354df3d..62813e1b 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 047e617b..305f81b6 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 82f787d8..a72c3de7 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 66dcb724..eb87111b 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 85cbe6f0..8b47b7a0 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 84484127..32c04b58 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 4e375270..94e94071 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 1dd9d35e..6e38e50f 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 7c33e43a..5328389e 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 a2fc9df6..00000000 --- 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 6967fc25..e41cc395 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 9bdfc8a8..eb264d27 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 731cbfb2..565a7cf1 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 00000000..e7a3fc48 --- /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 00000000..8e30ef5f --- /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 a9902280..edf87ad2 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 fb1ff38d..f086f1aa 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 a84a57e1..eea30286 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 24d7822d..76eba9c6 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 d75849c4..bb922ee8 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 122ea043..b8de2150 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 f70fadc5..82442c41 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; -- 2.51.2