Compare commits

..

97 Commits

Author SHA1 Message Date
Danny Avila
3b4a0517e9 chore: Remove ClosePlugin from Vite config 2024-08-28 13:00:01 -04:00
Danny Avila
08d2a88e70 chore: close plugin 2024-08-28 11:33:23 -04:00
Danny Avila
be2abf09cc chore: install/declare rollup at latest, update vite precache 2024-08-28 11:15:39 -04:00
Danny Avila
7c1ee242eb 🪄 feat: Code Artifacts (#3798)
* feat: Add CodeArtifacts component to Beta settings tab

* chore: Update npm dependency to @codesandbox/sandpack-react@2.18.2

* WIP: artifacts first pass

* WIP first pass remark-directive

* chore: revert markdown to original component + new artifacts rendering

* refactor: first pass rewrite

* refactor: add throttling

* first pass styling

* style: Add Radix Tabs, more styling changes

* feat: second pass

* style: code styling

* fix: package markdown fixes

* feat: Add useEffect hook to Artifacts component for visibility control, slide in animation

* fix: only set artifact if there is content

* refactor: typing and make latest artifact active if the number of artifacts changed

* feat: artifacts + shadcnui

* feat: Add Copy Code button to Artifacts component

* feat: first pass streaming updates

* refactor: optimize ordering of artifacts in Artifacts component

* refactor: optimize ordering of artifacts and add latest artifact activation in Artifacts component

* refactor: add order prop to Artifact

* feat: update to latest, use update time for ordering

* refactor: optimize ordering of artifacts and activate latest artifact in Artifacts component

* wip: remove thinking text and artifact formatting if empty

* refactor: optimize Markdown rendering and add support for code artifacts

* feat: global state for current artifact Id and set on artifact preview click

* refactor: Rename CodePreview component to ArtifactButton

* refactor: apply growth to artifact frame so artifact preview can take full space

* refactor: remove artifactIdsState

* refactor: nullify artifact state and reset on empty conversation

* feat: reset artifact state on conversation change

* feat: artifacts system prompt in backend

* refactor: update UI artifact toggle label to match localization key

* style: remove ArtifactButton inline-block styling

* feat: memoize ArtifactPreview, add html support

* refactor: abstract out components

* chore: bump react-resizable-panel

* refactor: resizable panel order props

* fix: side panel resizing crashes

* style: temporarily remove scrolling, add better styling

* chore: remove thinking for now

* chore: preprocess artifacts for now

* feat: Add auto scrolling to CodeMarkdown (artifacts)

* feat: autoswitch to preview

* feat: auto switch to code, adjust prompt, remove unused code

* feat: refresh button

* feat: open/close artifacts

* wip: mermaid

* refactor: w-fit Artifact button

* chore: organize code

* feat: first pass mermaid

* refactor: improve panning logic in MermaidDiagram component

* feat: center/zoom on first render

* refactor: add centering with reset button

* style: mermaid styling

* refactor: add back MermaidDiagram

* fix: static/html template

* fix: mermaid

* add examples to artifacts prompt

* refactor: fix CodeBar plugin prop logic

* refactor: remove unnecessary mention of artifacts when not requested

* fix: remove preprocessCodeArtifacts function and fix imports

* feat: improve artifacts guidelines and remove unnecessary mentions

* refactor: improve artifacts guidelines and remove unnecessary mentions

* chore: uninstall unused packages

* chore: bump vite

* chore: update three dependency to version 0.167.1

* refactor: move beta settings, add additional artifacts toggles

* feat: artifacts mode toggles

* refactor: adjust prompt

* feat: shadcnui instructions

* feat: code artifacts custom prompt mode

* chore: Update artifacts UI labels and instructions localizations

* refactor: Remove unused code in Markdown component
2024-08-27 17:03:16 -04:00
Danny Avila
80e1bdc282 v0.7.5-rc1 (#3807) 2024-08-27 15:01:41 -04:00
Danny Avila
a267f6e0da 🔍 fix: USE_REDIS condition, Markdown list counter, code highlights (#3806)
* fix: markdown rehype highlight accidental removal

* fix: USE_REDIS condition check

* fix: markdown list counter
2024-08-27 14:49:43 -04:00
Danny Avila
f742b9972e 🏷️ chore: Add Unofficial Naming Variation for Claude-3.5-Sonnet (#3800) 2024-08-27 09:07:04 -04:00
Danny Avila
bbaa0ee1cf 🚚 chore: Remove client-dist volume from deploy-compose.yml (#3799) 2024-08-27 07:18:47 -04:00
Marco Beretta
62881fee54 🔧 fix: handle missing custom config speech (#3790)
* feat: Update speech settings retrieval logic to handle missing custom configuration

This commit updates the logic in the Speech component and the getCustomConfigSpeech function to handle the case where the custom configuration is missing. Previously, if no custom configuration was found, an error would occur. Now, the code checks for the presence of the custom configuration and returns a message indicating that no custom configuration was found. This improves the robustness of the application and provides a better user experience.

* refactor: changed response message when no custom config is found
2024-08-27 06:09:04 -04:00
Danny Avila
34fd960d54 🧹 chore: remove legacy markdown code (#3789) 2024-08-26 17:53:19 -04:00
Danny Avila
5694ad4e55 🧠 feat: Prompt caching switch, prompt query params; refactor: static cache, prompt/markdown styling, trim copied code, switch new chat to convo URL (#3784)
* refactor: Update staticCache to use oneDayInSeconds for sMaxAge and maxAge

* refactor: role updates

* style: first pass cursor

* style: Update nested list styles in style.css

* feat: setIsSubmitting to true in message handler to prevent edge case where submitting turns false during message stream

* feat: Add logic to redirect to conversation page after creating a new conversation

* refactor: Trim code string before copying in CodeBlock component

* feat: configSchema bookmarks and presets defaults

* feat: Update loadDefaultInterface to handle undefined config

* refactor: use  for compression check

* feat: first pass, query params

* fix: styling issues for prompt cards

* feat: anthropic prompt caching UI switch

* chore: Update static file cache control defaults/comments in .env.example

* ci: fix tests

* ci: fix tests

* chore:  use "submitting" class server error connection suspense fallback
2024-08-26 15:34:46 -04:00
Fuegovic
bd701c197e 🌀 feat: Known Endpoints - Unify (#3778)
Signed-off-by: Fuegovic <fueg@live.ca>
2024-08-25 19:10:25 -04:00
Danny Avila
9bfe40bfcd ⏺️ style: Better Markdown Lists (#3777)
* style: markdown lists

* style: markdown lists, second pass

* style: update nested list styles in style.css
2024-08-25 16:33:25 -04:00
Fuegovic
07f520100d 🐋 feat: Known Endpoints: DeepSeek (#3776)
Signed-off-by: Fuegovic <fueg@live.ca>
2024-08-25 15:33:03 -04:00
Danny Avila
d52e81bde6 🐋 ci: Dockerfile.multi rewrite, maintain package integrity pt. 2 2024-08-24 15:09:38 -04:00
Danny Avila
88d8706757 🐋 ci: Dockerfile.multi rewrite, maintain package integrity (#3772)
* chore: touch server

* chore: dockerfile.multi first pass

* refactor: remove prod-stage build, share dist files instead
2024-08-24 15:00:51 -04:00
Danny Avila
d54458b3a6 🧮 feat: Improve structured token spending and testing; fix: Anthropic Cache Spend (#3766)
* chore: update jest and mongodb-memory-server dependencies

* fix: Anthropic edge case of increasing balance

* refactor: Update token calculations in Transaction model/spec

* refactor: `spendTokens` always record transactions, add Tx related tests

* feat: Add error handling for CHECK_BALANCE environment variable

* feat: Update import path for Balance model in Balance controller

* chore: remove logging

* refactor: Improve structured token spend logging in spendTokens.js

* ci: add unit tests for spend token

* ci: Improve structured token spend unit testing

* chore: improve logging phrase for no tx spent from balance
2024-08-24 04:36:08 -04:00
Danny Avila
ea5140ff0f 🧮 feat: Improve LaTeX rendering consistency (#3763)
* refactor: simplify LaTeX pre-processing for more consistent rendering, disables `singleDollarTextMath`

* refactor: disable singleDollarTextMath in all markdown components

* wip: first pass

* refactor: preserve code blocks and convert rather than preserve LaTeX delimiters

* refactor: remove unused escapeDollarNumber function from latex.ts
2024-08-23 13:45:27 -04:00
Danny Avila
967e8a1e92 🍎 refactor(a11y): Optimize Live Region Announcements for Apple VoiceOver (#3762)
* refactor: first pass rewrite

* refactor: update CLEAR_DELAY to 5000 milliseconds in LiveAnnouncer.tsx

* refactor: assertive messages to clear queue immediately, fix circular useCallback dependency issue

* chore: comment
2024-08-23 10:22:16 -04:00
Danny Avila
f86e9dd04c 🔖 feat: Enhance Bookmarks UX, add RBAC, toggle via librechat.yaml (#3747)
* chore: update package version to 0.7.416

* chore: Update Role.js imports order

* refactor: move updateTagsInConvo to tags route, add RBAC for tags

* refactor: add updateTagsInConvoOptions

* fix: loading state for bookmark form

* refactor: update primaryText class in TitleButton component

* refactor: remove duplicate bookmarks and theming

* refactor: update EditIcon component to use React.forwardRef

* refactor: add _id field to tConversationTagSchema

* refactor: remove promises

* refactor: move mutation logic from BookmarkForm -> BookmarkEditDialog

* refactor: update button class in BookmarkForm component

* fix: conversation mutations and add better logging to useConversationTagMutation

* refactor: update logger message in BookmarkEditDialog component

* refactor: improve UI consistency in BookmarkNav and NewChat components

* refactor: update logger message in BookmarkEditDialog component

* refactor: Add tags prop to BookmarkForm component

* refactor: Update BookmarkForm to avoid tag mutation if the tag already exists; also close dialog on submission programmatically

* refactor: general role helper function to support updating access permissions for different permission types

* refactor: Update getLatestText function to handle undefined values in message.content

* refactor: Update useHasAccess hook to handle null role values for authenticated users

* feat: toggle bookmarks access

* refactor: Update PromptsCommand to handle access permissions for prompts

* feat: updateConversationSelector

* refactor: rename `vars` to `tagToDelete` for clarity

* fix: prevent recreation of deleted tags in BookmarkMenu on Item Click

* ci: mock updateBookmarksAccess function

* ci: mock updateBookmarksAccess function
2024-08-22 17:09:05 -04:00
Danny Avila
366e4c5adb 🔧 fix: EndpointIcon crash when using @ mention command (#3742) 2024-08-22 10:49:51 -04:00
Danny Avila
596ecc6969 🔐 feat: Toggle Access to Prompts via librechat.yaml (#3735)
* chore: update CONFIG_VERSION to '1.1.6'

* chore: update package version to 0.7.415

* feat: toggle USER role access to prompts via librechat.yaml

* refactor: set prompts to true when loadDefaultInterface returns true

* ci(AppService): mock updatePromptsAccess
2024-08-21 19:57:34 -04:00
Marco Beretta
0c5568b80b ⌨️ style(a11y): kb access for LLM endpoint menu; refactor: style (#3714) 2024-08-21 18:19:33 -04:00
Danny Avila
98b437edd5 🖱️ fix: Message Scrolling UX; refactor: Frontend UX/DX Optimizations (#3733)
* refactor(DropdownPopup): set MenuButton `as` prop to `div` to prevent React warning: validateDOMNesting(...): <button> cannot appear as a descendant of <button>

* refactor: memoize ChatGroupItem and ControlCombobox components

* refactor(OpenAIClient): await stream process finish before finalCompletion event handling

* refactor: update useSSE.ts typing to handle null and undefined values in data properties

* refactor: set abort scroll to false on SSE connection open

* refactor: improve logger functionality with filter support

* refactor: update handleScroll typing in MessageContainer component

* refactor: update logger.dir call in useChatFunctions to log 'message_stream' tag format instead of the entire submission object as first arg

* refactor: fix null check for message object in Message component

* refactor: throttle handleScroll to help prevent auto-scrolling issues on new message requests; fix type issues within useMessageProcess

* refactor: add abortScrollByIndex logging effect

* refactor: update MessageIcon and Icon components to use React.memo for performance optimization

* refactor: memoize ConvoIconURL component for performance optimization

* chore: type issues

* chore: update package version to 0.7.414
2024-08-21 18:18:45 -04:00
Marco Beretta
ba9c351435 🔧 fix: add clear all button to bookmark navigation items (#3721) 2024-08-20 19:07:51 -04:00
Konstantin
58334dffea 🌏i18n: Improve Russian translation (#3718)
* Small correction

* Small correction 1

* Small corrections 2
2024-08-20 18:51:02 -04:00
Danny Avila
45479d468e fix(staticCache): NODE_ENV check 2024-08-20 07:32:30 -04:00
Danny Avila
14ddfec9b7 🧹 chore: address minor issues (#3710)
* refactor(OpenAIClient): improve error handling in chat completion

Improve error handling in the chat completion process to handle scenarios where the response has no choices or the message is undefined. This ensures that the intermediate reply is returned in such cases. Also, add logging statements for better debugging.

* refactor(Markdown): only show blinking cursor for latest message

* refactor: add submitting selector to prevent blinking cursor for empty, non-streaming, non-latest messages
2024-08-20 07:00:43 -04:00
Marco Beretta
5de3b8a148 🏷️ a11y: add aria-label to close button and aria-hidden to SVG (#3698)
Fixes #3641

Add accessible name to the close button for added conversations and hide SVG from assistive technology.

* Add `aria-label="Close added conversation"` to the `button` element.
* Add `aria-hidden="true"` to the `svg` element.

---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/danny-avila/LibreChat/issues/3641?shareId=XXXX-XXXX-XXXX-XXXX).
2024-08-19 18:08:29 -04:00
Marco Beretta
d4c0f7267a 🔑 fix(AuthService): properly handle reading and deletion of password reset token (#3697) 2024-08-19 17:55:33 -04:00
Danny Avila
cebb3751c1 📣 a11y: Better Screen Reader Announcements (#3693)
* refactor: Improve LiveAnnouncer component

The LiveAnnouncer component in the client/src/a11y/LiveAnnouncer.tsx file has been refactored to improve its functionality. The component now processes text in chunks for better performance and adds a minimum announcement delay to prevent overlapping announcements. Additionally, the component now properly clears the announcement message and ID before setting a new one. These changes enhance the accessibility and user experience of the LiveAnnouncer component.

* refactor: manage only 2 LiveAnnouncer aria-live elements, queue assertive/polite together

* refactor: use localizations for event announcements

* refactor: update minimum announcement delay in LiveAnnouncer component

* refactor: replace *`_

* chore(useContentHandler): typing

* chore: more type fixes and safely announce final message
2024-08-19 03:51:17 -04:00
Danny Avila
598e2be225 🗨️ refactor(VariableForm): use InputCombobox, fix Dropdown Variables (#3692)
* feat: Add SimpleCombobox component

* feat: Add labelClassName and add manual focus handling

* feat: Update VariableForm component to use SimpleCombobox

The VariableForm component in the client/src/components/Prompts/Groups/VariableForm.tsx file has been updated to use the SimpleCombobox component instead of the InputWithDropdown component. This change improves the functionality and styling of the form.

* chore: Update VariableForm component placeholder text

* refactor: Improve VariableForm component

The VariableForm component in the client/src/components/Prompts/Groups/VariableForm.tsx file has been refactored to improve its functionality. The `parseFieldConfig` function now trims the `variable` string before processing it. Additionally, the `onSubmit` function now properly escapes potential regex special chars that may cause issues when replacing text

* refactor: Improve VariableForm using ariakit helpers/custom fields, open menu on input focus

* refactor: rename SimpleCombobox to InputCombobox
2024-08-18 22:23:19 -04:00
Danny Avila
8ca1e4f94f 🐛 fix: Anthropic Prompt Caching Edge Case (#3690) 2024-08-18 19:03:03 -04:00
Danny Avila
87d95a9d82 📱 fix: Resolve Android Device and Accessibility Issues of Sidebar Combobox (#3689)
* chore: Update @ariakit/react dependency to version 0.4.8

* refactor: Fix Combobox Android issue with radix-ui

* fix: Improve scrolling behavior by setting abort scroll state to false after scrolling to end

* wip: first pass switcher rewrite

* feat: Add button width calculation for ComboboxComponent

* refactor: Update ComboboxComponent styling for improved layout and appearance

* refactor: Update AssistantSwitcher component to handle null values for assistant names and avatar URLs

* refactor: Update ModelSwitcher component to use SimpleCombobox for improved functionality and styling

* refactor: Update Switcher Separator styling for improved layout and appearance

* refactor: Improve accessibility by adding aria-label to ComboboxComponent select items

* refactor: rename SimpleCombobox -> ControlCombobox
2024-08-18 19:02:46 -04:00
Konstantin
b22f1c166e 🌏 i18n: Improve Russian translation (#3674) 2024-08-18 13:23:13 -04:00
Danny Avila
683702d555 🤖 refactor: Remove Default Model Params for All Endpoints (#3682)
* refactor: use parseCompactConvo in buildOptions, and generate no default values for the API to avoid weird model behavior with defaults

* refactor: OTHER - always show cursor when markdown component is empty (preferable to not)

* refactor(OpenAISettings): use config object for setting defaults app-wide

* refactor: Use removeNullishValues in buildOptions for ALL  endpoints

* fix: add missing conversationId to title methods for transactions; refactor(GoogleClient): model options, set no default, add todo note for recording token usage

* fix: at minimum set a model default, as is required by API (edge case)
2024-08-18 06:00:03 -04:00
Danny Avila
d3a20357e9 🧪 feat: Prompt Dropdown Variable; style: Add Markdown Support (#3681)
* feat: Add extended inputs for promts library variables

* feat: Add maxRows prop to VariableForm input field

* 📩 feat: invite user (#3012)

* feat: basic invite-user script

* feat: add invite user functionality and registration validation middleware

* fix: invite user fixes

* refactor: consolidate direct model access to a central place of functions

* style(Registration): add spinner to continue button

* refactor: import ordrer

* feat: improve invite user script and error handling

* fix: merge conflict

* refactor: remove `console.log` and use `logger`

* fix: token operation and checkinvite issues

* bring back comment and remove console log

* fix: return invalid token when token is not found

* fix: getInvite fix

* refactor: Update Token.js to use async/await syntax for update and delete operations

* feat: Refactor Token.js to use async/await syntax for createToken and findToken functions

* refactor(inviteUser): define functions outside of module.exports

* Update AuthService.js

---------

Co-authored-by: Danny Avila <danny@librechat.ai>

* style: improve OpenAI.tsx input field focus styling

* refactor: update import statement in Input.tsx

* refactor: remove multi-line

* refactor:  update placeholder text to use localization

* style: new dropdown variable info and markdown styling for info

* Add ReactMarkdown

* chore: styling, import order

* refactor: update ReactMarkdown usage in VariableForm

* style: remove markdown class

* refactor: update mobile styling and use code renderer

* style(InputWithDropDown): update focus trigger style

* style(OptionsPopover): update Save As Preset `focus` and `dark:bg`

---------

Co-authored-by: Konstantin Meshcheryakov <kmeshcheryakov@klika-tech.com>
Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Co-authored-by: bsu3338 <bsu3338@users.noreply.github.com>
2024-08-18 05:52:05 -04:00
Marco Beretta
bbb9324447 📩 feat: invite user (#3012)
* feat: basic invite-user script

* feat: add invite user functionality and registration validation middleware

* fix: invite user fixes

* refactor: consolidate direct model access to a central place of functions

* style(Registration): add spinner to continue button

* refactor: import ordrer

* feat: improve invite user script and error handling

* fix: merge conflict

* refactor: remove `console.log` and use `logger`

* fix: token operation and checkinvite issues

* bring back comment and remove console log

* fix: return invalid token when token is not found

* fix: getInvite fix

* refactor: Update Token.js to use async/await syntax for update and delete operations

* feat: Refactor Token.js to use async/await syntax for createToken and findToken functions

* refactor(inviteUser): define functions outside of module.exports

* Update AuthService.js

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-08-18 00:23:38 -04:00
Danny Avila
a45b384bbc 💾 feat: Anthropic Prompt Caching (#3670)
* wip: initial cache control implementation, add typing for transactions handling

* feat: first pass of Anthropic Prompt Caching

* feat: standardize stream usage as pass in when calculating token counts

* feat: Add getCacheMultiplier function to calculate cache multiplier for different valueKeys and cacheTypes

* chore: imports order

* refactor: token usage recording in AnthropicClient, no need to "correct" as we have the correct amount

* feat: more accurate token counting using stream usage data

* feat: Improve token counting accuracy with stream usage data

* refactor: ensure more accurate than not token estimations if custom instructions or files are not being resent with every request

* refactor: cleanup updateUserMessageTokenCount to allow transactions to be as accurate as possible even if we shouldn't update user message token counts

* ci: fix tests
2024-08-17 03:24:09 -04:00
Marco Beretta
9f4c516615 fix: Export Button Content Shift; chore: bump axios and add logging (#3668)
* chore: bump axios version

* fix: export button glitch

* fix: remove console logs
2024-08-16 18:03:27 -04:00
Danny Avila
16c9aed1bb 🤖 feat: Recognize chatgpt-4o-latest, update default OpenAI Models (#3667)
* 🤖 feat: recognize chatgpt-4o-latest, update default OpenAI models

* chore: Bump data-provider version to 0.7.412
2024-08-16 15:28:17 -04:00
Danny Avila
3826af5909 🤲 a11y: Keyboard Accessibility for Account Settings (#3666)
* refactor(NavLinks -> AccountSettings): add theming, fix minor type issues, and rename component

* fix: closes #3499 - Account settings menu is not keyboard accessible

* fix(DataTable): type issue causing application crash, update contextMap key for storage
2024-08-16 15:09:03 -04:00
Arthur Barrett
6ad65ff065 🔧 fix: Delete Archived Chat z-index issue (#3643) 2024-08-16 13:53:38 -04:00
Danny Avila
91fc89076f 🤲 a11y: Sidebar Text Contrast (#3665)
* fix: use theme class for valid contrast for light/dark, conversation group names, fix type issues

* fix: searchbar text contrast styling, closes #3469

* style: add theming for prompt cards, fix a11y contrast issues

* a11y: address placeholder text contrast issues in LLM controls section

* chore: Add conditional check for pull request repository in a11y workflow

* style: Update text color contrast to WCAG standard for placeholder and focus states in AssistantPanel components
2024-08-16 13:50:47 -04:00
Danny Avila
f8a5dad265 🦙 fix: Update Title Message Role for Ollama if None Provided (#3663) 2024-08-16 04:54:37 -04:00
Marco Beretta
96581d56df 🖼️ style: Conversation Menu and Dialogs update (#3601)
* feat: new dropdown

* fix: maintain popover active when open

* fix: update DeleteButton and ShareButton component to use useState for managing dialog state

* BREAKING: style improvement of base Button component

* style: update export button

* a11y: ExportAndShareButton

* add border

* quick style fix

* fix: flick issue on convo

* fix: DropDown opens when renaming

* chore: update radix-ui/react-dropdown-menu to latest

* small fix

* style: bookmarks update

* reorder export modal

* feat: imporved dropdowns

* style: a lot of changes; header, bookmarks, export, nav, convo, convoOptions

* fix: small style issues

* fix: button

* fix: bookmarks header menu

* fix: dropdown close glitch

* feat: Improve accessibility and keyboard navigation in ModelSpec component

* fix: Nav related type issues

* style: ConvoOptions theming and focus ring

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-08-16 04:30:14 -04:00
Vesna Tan
7f50d2f7c0 🤲 a11y: Add landmark to footer, labels updates, and sidebar text contrast (#3630)
* add landmark contentinfo to footer

* fix aria-label ai model menuitem for issue #3587

* changed group/today text color to #535353
2024-08-16 03:27:56 -04:00
Akash
93cf7bab02 🤲 a11y: Update Chat History aria-label & Controls role (#3604) 2024-08-16 03:12:09 -04:00
Yuichi Oneda
e47c3f40f0 🔧 fix: Bookmark Order Adjustment When Moving Up (#3634) 2024-08-16 03:01:35 -04:00
Danny Avila
0ba08b15de chore: Add conditional check for pull request repository in a11y workflow 2024-08-16 02:50:23 -04:00
Danny Avila
16e1f74a6c fix(useTextToSpeechBrowser): handle undefined window.speechSynthesis on some android devices 2024-08-16 02:49:59 -04:00
Danny Avila
dba704079c 🔀 refactor: Modularize TTS Logic for Improved Browser support (#3657)
* WIP: message audio refactor

* WIP: use MessageAudio by provider

* fix: Update MessageAudio component to use TTSEndpoints enum

* feat: Update useTextToSpeechBrowser hook to handle errors and improve error logging

* feat: Add voice dropdown components for different TTS engines

* docs: update incorrect `voices` example

changed `voice: ''` to `voices: ['alloy']`

* feat: Add brwoser support check for Edge TTS engine component with error toast if not supported

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2024-08-15 11:34:25 -04:00
Danny Avila
bcde0beb47 🛠️ feat: Azure OpenAI Assistants File Downloads (#3653) 2024-08-15 09:10:12 -04:00
Marco Beretta
741e3e8395 🚀 Update README - v0.7.4 2024-08-15 00:40:59 -04:00
Danny Avila
dc8d30ad90 🎧 fix(TTS): Improve State of audio playback, hook patterns, and fix undefined MediaSource (#3632) 2024-08-13 12:08:55 -04:00
Danny Avila
e3ebcfd2b1 🎙️ fix: Optimize and Fix Browser TTS Incompatibility (firefox) (#3627)
* fix: 'disable' MsEdgeTTS on unsupported browser (firefox)

* refactor: only pass necessary props to HoverButton MessageAudio

* refactor: Fix conditional comparison operators in MessageAudio component

* refactor: Remove console.log statement in MessageAudio component
2024-08-13 04:14:37 -04:00
Danny Avila
6655304753 🎙️ a11y: Screen Reader Support for Dynamic Content Updates (#3625)
* WIP: first pass, hooks

* wip: isStream arg

* feat: first pass, dynamic content updates, screen reader announcements

* chore: unrelated, styling redundancy
2024-08-13 03:04:27 -04:00
Danny Avila
05696233a9 🎛️ fix: Improve Frontend Practices for Audio Settings (#3624)
* refactor: do not call await inside useCallbacks, rely on updates for dropdown

* fix: remember last selected voice

* refactor: Update Speech component to use TypeScript in useCallback

* refactor: Update Dropdown component styles to match header theme
2024-08-13 02:42:49 -04:00
Marco Beretta
8cbb6ba166 📧 fix: @/+command timing for click selections - closes #3613 (#3617) 2024-08-12 09:06:23 -04:00
Danny Avila
02847af580 📜 refactor: Optimize Longer Message Thread Performance (#3610) 2024-08-11 06:08:08 -04:00
Danny Avila
cf69b7ef85 v0.7.4 2024-08-10 09:09:37 -04:00
Danny Avila
3a5f57fd2b v0.7.4 (#3603) 2024-08-10 08:32:27 -04:00
Danny Avila
22c8b6f699 💭 fix: Message Labels and Typing Issues (#3602)
* 💭 fix: message labels due to typing issues

* feat: Fix empty message text in MessageRender component
2024-08-09 17:59:51 -04:00
Danny Avila
1ff4841603 🧹 chore: pre-release cleanup 2 (#3600)
* refactor: scrollToEnd

* fix(validateConvoAccess): search conversation by ID for proper validation

* feat: Add unique index for conversationId and user in convoSchema

* refactor: Update font sizes 1 rem -> font-size-base in style.css

* fix: Assistants map type issues

* refactor: Remove obsolete scripts

* fix: Update DropdownNoState component to handle both string and OptionType values

* refactor: Remove config/loader.js file

* fix: remove crypto.randomBytes(); refactor: Create reusable function for generating token and hash
2024-08-09 15:17:13 -04:00
Danny Avila
6fead1005b ci: deploy test server post-dev image build pt. 3 2024-08-09 02:55:33 -04:00
Danny Avila
8a9476a795 ci: deploy test server post-dev image build pt. 2 2024-08-09 02:39:29 -04:00
Danny Avila
59ae2914aa 🐋 ci: deploy test server post-dev image build (#3596) 2024-08-09 02:22:25 -04:00
Danny Avila
473859b0e2 🧹 chore: pre-release cleanup (#3595)
* fix: Update health endpoint URL

* refactor: use Constants for saved tag default value, do not place Saved as first always

* refactor: check trimmed currentText before appending parsedText in useSubmitMessage

* refactor: move `scrollToEnd()` to `createdHandler` and increase delay before execution slightly

* chore: Add back TypeScript linting rules for unnecessary conditions and strict boolean expressions

* chore: Update librechat-data-provider package.json version to 0.7.4.0
2024-08-09 02:11:56 -04:00
Danny Avila
e05a6d306d 🪙 feat: Update Token Values for gpt-4o-2024-08-06 and AWS Models (#3594)
* feat: gpt-4o-2024-08-06 pricing for tx

* feat: add AWS models to tokenValues in tx.js for pricing transactions

* feat: Update tokenValues in tx.js for AWS models pricing

* refactor: add bedrock prefix values as well (temporary until we update value keys which includes context)
2024-08-08 23:31:07 -04:00
Danny Avila
016ed866a3 🏷️ fix: Address Statefulness Issues for Bookmarks (#3590)
* refactor: optimize tag methods, remove rebuild

* refactor(tags): add lean db operations, fix updateTagsForConversation, remove rebuild button, only send convoId once

* refactor: Update BookmarkMenu to use Constants.NEW_CONVO constant for comparison

* style: Update BookmarkMenu styles and constants, use theming

* refactor: move tags query from package to client workspace

* refactor: optimize ConversationTag document creation and update logic

* style: Update BookmarkMenuItems to use theming

* refactor: JSDocs + try/catch for conversation tags API routes

* refactor: Update BookmarkNav theming classes and new data provider location

* fix: statefulness of conversation bookmarks
- move non-mutation hook to hooks/Conversation
- remove use of deprecated global convo
- update convo infinite data as well as current convo state upon successful tag add

* refactor: Update BookmarkMenu styles and constants, use theming

* refactor: Add lean option to ConversationTag deletion query

* fix(BookmarkTable): position order rendering esp. when new tag is created

* refactor: Update useBookmarkSucess to useBookmarkSuccess for consistency

* refactor: Update ConversationTag creation logic to increment count only if addToConversation is true

* style: theming
2024-08-08 21:25:10 -04:00
Danny Avila
6ea2628b56 🌡️ feat: Periodic Health Check to prevent UI Inactivity Connection Errors (#3589)
* 🌡️ feat: Periodic Health Check to prevent UI Inactivity Connection Errors

* feat: Add refetchOnWindowFocus option for health check

* feat: programmatically scroll to end when a chat request is initiated (and messages have rendered)
2024-08-08 14:52:12 -04:00
Marco Beretta
cf393b1308 🎨 style: overall UI improvements (#3576)
* stlye: new dialogs

* style: DashGroupItem

* style: bookmarks update
2024-08-08 12:16:17 -04:00
Danny Avila
5c99d93744 🛂 feat: Added Security for Conversation Access (#3588)
* 🛂 feat: Added Security for Conversation Access

* refactor: Update concurrentLimiter and convoAccess middleware to use isEnabled function for Redis check

* refactor: handle access check even if cache is not available (edge case)
2024-08-08 12:14:00 -04:00
Danny Avila
b3821c1404 🎨 style: Enhance UI/UX for Font Size, Mentions, and Prompts (#3575)
* style: fix prompts icon shrinking in command popover

* fix: scroll into view behavior for mentions

* fix: always apply default font size if not found

* refactor: Update useMessageScrolling threshold and debounceRate
2024-08-08 10:02:30 -04:00
Danny Avila
2bb0842650 🅰️ feat: Dynamic Font Size (#3568)
* wip: general setup

* added: translations for font-size

* fix: prompts related linter errors and add theming

* wip: font size selector

* refactor: Update FontSizeSelector options display property

* refactor: adjust Intersection Observer threshold and debounce rate

* feat: Update selectedPrompt type in PromptForm to be optional

* feat: dynamic font size

* refactor: Update message font size in navigation bar

* refactor: Update code analyze block styling

* refactor: ProgressText dynamic font size

* refactor: move FontSizeSelector component to Chat from General settings

* fix: HoverButtons styling for better visibility

* refactor: Update HoverButtons styling for better visibility

---------

Co-authored-by: kraken <solodarken@gmail.com>
2024-08-07 14:23:33 -04:00
Marco Beretta
b390ba781f 🗣️ feat: Edge TTS engine (#3358)
* feat: MS Edge TTS

* feat: Edge TTS; fix: STT hook
2024-08-07 14:15:41 -04:00
Danny Avila
01a88991ab 🎛️ refactor: Add keyboard command toggles & only trigger for the 1st character (#3566)
* refactor: Improve handling of key up events in useHandleKeyUp hook to only trigger for first char and fix linter issues

* refactor: Update Beta component styling with theme twcss variables

* refactor: theming for Settings, add toggle enable/disable keyboard commands
2024-08-06 22:18:07 -04:00
Danny Avila
270c6d2350 📜 fix: Better OpenAI Assistants Annotation Processing (#3565)
* fix: correctly handle openai assistants annotations

* fix: Handle adjacent identical citations only for our specific format

* fix: correctly handle adjacent identical citations

* refactor: make regex handling more robust

* fix: skip annotation condition, make regex handling more robust

* refactor: Handle FILE_PATH and FILE_CITATION annotation types in processMessages.spec.js

* ci: unit tests for real file path type annotations
2024-08-06 18:52:58 -04:00
Danny Avila
c2a79aee1b feat: Optimize Scroll Handling with Intersection Observer (#3564)
*  refactor(ScrollToBottom): use Intersection Observer for efficient scroll handling

* chore: imports, remove debug console
2024-08-06 16:18:15 -04:00
Dongwoo Jeong
6879de0bf1 🔧 fix: API Key Handling for GoogleSearch and TavilySearch Tools (#3541)
* fix: get user-provided API key using environment variable names as keys

* feat: Add error handling for missing API key and search engine ID

* feat: Add GoogleSearch and TavilySearchResults specs for environment variables handling

---------

Co-authored-by: Dongwoo Jeong <dongwoo.jeong@lge.com>
2024-08-05 19:29:16 -04:00
Danny Avila
80773d0bce 🗝️ fix: update webcrypto algorithm name as aes-256-cbc is unrecognized (#3556) 2024-08-05 19:15:32 -04:00
Danny Avila
458dc9c88e 🛡️ feat: Optimize and Improve Anonymity of SharedLinks (#3543)
* ci: add spec for validateImageRequest

* chore: install nanoid and run npm audit fix

* refactor: optimize and further anonymize shared links data

* ci: add SharedLink specs

* feat: anonymize asst_id's

* ci: tests actually failing, to revisit later

* fix: do not anonymize authenticated shared links query

* refactor: Update ShareView component styling

* refactor: remove nested ternary

* refactor: no nested ternaries

* fix(ShareView): eslint warnings

* fix(eslint): remove nested terns
2024-08-05 03:34:00 -04:00
matt burnett
3e0f95458f 🕸️ refactor: Migrate from crypto to Web Crypto API (#3357)
* move crypto to async webcrypto

update encrypt/decrypt

forgot await

* chore: import order - openidStrategy.js

* chore: import order - Session.js

* chore: import order - AuthController.js

* Update AuthService.js

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2024-08-04 23:59:45 -04:00
Danny Avila
b6fe7e5570 🪖 feat: Helm Chart (#3539)
* chore: adding helm chart for librechat

Signed-off-by: Noah Ispas (iamNoah1) <noahispas@gmail.com>

* chore: delete docs, move to docs repo

---------

Signed-off-by: Noah Ispas (iamNoah1) <noahispas@gmail.com>
Co-authored-by: Noah Ispas (iamNoah1) <noahispas@gmail.com>
2024-08-04 23:43:30 -04:00
matt burnett
e4ac42f034 feat: Static File Caching (#3455)
* add static file cache

* disable compression env variable
2024-08-04 21:17:59 -04:00
Linh
020c4a1a0e 📝 docs: RAG API service communicate with Ollama service on Linux (#3356)
* Update docker-compose.override.yml.example for running RAG API with Ollama nomic-embed-text

* Update docker-compose.override.yml.example
2024-08-04 20:57:02 -04:00
Michael Clark
5bbe424137 📝 docs: update .env.example with RAG API variables (#3461)
* Update .env.example

* Update .env.example

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2024-08-04 20:54:51 -04:00
Danny Avila
5baa95bd9a 🖼️ fix: correct image extraction (#3538)
* fix: regex issue extracting text with images in markdown

* fix: update addImages function to include only the first observed image path in the response message

* ci: tests for addImages function: correct image extraction

* fix(GoogleClient): linting

---------

Co-authored-by: Dongwoo Jeong <dongwoo.jeong@lge.com>
Co-authored-by: Dongwoo Jeong <dongwoo@duck.com>
2024-08-04 20:53:11 -04:00
Marlon
389b2a6cab 🌍 i18n: Improve German language translations (#3516)
* Improve German language translations

* 🌍 : Updated German Translations. Complete overhaul and manual improvement
2024-08-04 20:52:51 -04:00
Danny Avila
11bfed7126 🤲 feat(a11y): Initial a11y improvements, added linters, tests; fix: close sidebars in mobile view (#3536)
* chore: playwright setup update

* refactor: update ChatRoute component with accessible loading spinner with live region

* chore(Message): typing

* ci: first pass, a11y testing

* refactor: update lang attribute in index.html to "en-US"

* ci: jsx-a11y dev eslint plugin

* ci: jsx plugin

* fix: Exclude 'vite.config.ts' from TypeScript compilation for testing

* fix(a11y): Remove tabIndex from non-interactive element in MessagesView component

* fix(a11y):
- Visible, non-interactive elements with click handlers must have at least one keyboard listener.eslintjsx-a11y/click-events-have-key-events
- Avoid non-native interactive elements. If using native HTML is not possible, add an appropriate role and support for tabbing, mouse, keyboard, and touch inputs to an interactive content element.eslintjsx-a11y/no-static-element-interactions
chore: remove unused bookmarks panel
- fix some "Unexpected nullable boolean value in conditional" warnings

* fix(NewChat): a11y, nested button issue, add aria-label, remove implicit role

* fix(a11y):
- partially address #3515 with `main` landmark
other:
- eslint@typescript-eslint/strict-boolean-expressions

* chore(MenuButton): Use button element instead of div for accessibility

* chore: Update TitleButton to use button element for accessibility

* chore: Update TitleButton to use button element for accessibility

* refactor(ChatMenuItem): Improve focus accessibility and code readability

* chore(MenuButton): Update aria-label to dynamically include primaryText

* fix(a11y): SearchBar
- If a form control does not have a properly associated text label, the function or purpose of that form control may not be presented to screen reader users. Visible form labels also provide visible descriptions and larger clickable targets for form controls which placeholders do not.

* chore: remove duplicate SearchBar twcss

* fix(a11y):
- The edit and copy buttons that are visually hidden are exposed to Assistive technology and are announced to screen reader users.

* fix(a11y): visible focus outline

* fix(a11y): The button to select the LLM Model has the aria-haspopup and aria- expanded attributes which makes its role ambuguous and unclear. It functions like a combobox but doesn't fully support that interaction and also fucntions like a dialog but doesn't completely support that interaction either.

* fix(a11y): fix visible focus outline

* fix(a11y): Scroll to bottom button missing accessible name #3474

* fix(a11y): The page lacks any heading structure. There should be at least one H1 and other headings to help users understand the orgainzation of the page and the contents.

Note: h1 won't be correct here so made it h2

* fix(a11y): LLM controls aria-labels

* fix(a11y): There is no visible focus outline to the 'send message' button

* fix(a11y): fix visible focus outline for Fork button

* refactor(MessageRender): add focus ring to message cards, consolidate complex conditions, add logger for setting latest message, add tabindex for card

* fix: focus border color and fix set latest message card condition

* fix(a11y): Adequate contrast for MessageAudio buttton

* feat: Add GitHub Actions workflow for accessibility linting

* chore: Update GitHub Actions workflow for accessibility linting to include client/src/** path

* fix(Nav): navmask and accessibility

* fix: Update Nav component to handle potential undefined type in SearchContext

* fix(a11y): add focus visibility to attach files button #3475

* fix(a11y): discernible text for NewChat button

* fix(a11y): accessible landmark names, all page content in landmarks, ensures landmarks are unique #3514 #3515

* fix(Prompts): update isChatRoute prop to be required in List component

* fix(a11y): buttons must have discernible text
2024-08-04 20:39:52 -04:00
Oliver Faust
433d8f832a 🛠️fix: apply safety settings to Gemini API (#3533)
- Introduce getSafetySettings method for centralized safety settings
- Apply safety settings uniformly in sendCompletion for all models, including Gemini
- Ensure consistent safety settings application in getCompletion
- Update .env.example to clarify safety settings usage for both APIs
2024-08-04 14:08:57 -04:00
Xueshan Feng
62854c91d3 🐳 chore: Update Dockerfile.multi (#3528)
Upgrade nginx:1.21.1-alpine to nginx:1.27.0-alpine. Reduce Vulnerabilities report from 59 to 7 by Google image scan.
2024-08-04 06:43:07 -04:00
Gerkin
cd326c4cf0 💫 fix: catch constant conditions in recoil state usage (#3513)
* chore: modify eslintrc

* fix: apply new rules, avoid using getter/setter tuples in conditions

---------

Co-authored-by: Alexandre Germain <nihilivin@gmail.com>
2024-08-01 14:10:42 -04:00
Danny Avila
e15d0d354d refactor: update defaultCollapsed value to true for new users 2024-08-01 03:27:27 -04:00
Danny Avila
b8a6efa649 👷‍♂️ chore: pass Tavily Response error and revert test commit on userSchema.js (#3496)
* chore: revert test commit

* chore: make sure tavily error gets passed
2024-07-31 12:06:27 -04:00
Marco Beretta
27ab00c6a9 🔉 fix: Elevenlabs TTS and STT (#3482)
* fix: add `await` on getVoices

* fix: STT function

* fix: elevenlabs

* chore(lint): remove unnecessary whitespace in ldap.js

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2024-07-30 12:46:46 -04:00
Marco Beretta
51cd847606 🗣️ refactor: speech services; fix: OpenAI STT (#3431)
* fix: OpenAI STT

* refactor: STT and TTS service, slightly imporve of performance

* fix(DecibelSelector): update default value
2024-07-30 09:18:52 -04:00
456 changed files with 29394 additions and 9087 deletions

View File

@@ -65,6 +65,7 @@ PROXY=
# ANYSCALE_API_KEY=
# APIPIE_API_KEY=
# COHERE_API_KEY=
# DEEPSEEK_API_KEY=
# DATABRICKS_API_KEY=
# FIREWORKS_API_KEY=
# GROQ_API_KEY=
@@ -74,6 +75,7 @@ PROXY=
# PERPLEXITY_API_KEY=
# SHUTTLEAI_API_KEY=
# TOGETHERAI_API_KEY=
# UNIFY_API_KEY=
#============#
# Anthropic #
@@ -87,7 +89,6 @@ ANTHROPIC_API_KEY=user_provided
# Azure #
#============#
# Note: these variables are DEPRECATED
# Use the `librechat.yaml` configuration for `azureOpenAI` instead
# You may also continue to use them if you opt out of using the `librechat.yaml` configuration
@@ -117,7 +118,7 @@ BINGAI_TOKEN=user_provided
GOOGLE_KEY=user_provided
# GOOGLE_REVERSE_PROXY=
# Gemini API
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
# Vertex AI
@@ -125,26 +126,30 @@ GOOGLE_KEY=user_provided
# GOOGLE_TITLE_MODEL=gemini-pro
# Google Gemini Safety Settings
# NOTE (Vertex AI): You do not have access to the BLOCK_NONE setting by default.
# To use this restricted HarmBlockThreshold setting, you will need to either:
# Google Safety Settings
# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio)
#
# (a) Get access through an allowlist via your Google account team
# (b) Switch your account type to monthly invoiced billing following this instruction:
# https://cloud.google.com/billing/docs/how-to/invoiced-billing
# For Vertex AI:
# To use the BLOCK_NONE setting, you need either:
# (a) Access through an allowlist via your Google account team, or
# (b) Switch to monthly invoiced billing: https://cloud.google.com/billing/docs/how-to/invoiced-billing
#
# For Gemini API (AI Studio):
# BLOCK_NONE is available by default, no special account requirements.
#
# Available options: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE
#
# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH
# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH
#============#
# OpenAI #
#============#
OPENAI_API_KEY=user_provided
# OPENAI_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
# OPENAI_MODELS=gpt-4o,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
DEBUG_OPENAI=false
@@ -261,7 +266,6 @@ MEILI_NO_ANALYTICS=true
MEILI_HOST=http://0.0.0.0:7700
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
#==================================================#
# Speech to Text & Text to Speech #
#==================================================#
@@ -269,6 +273,16 @@ MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
STT_API_KEY=
TTS_API_KEY=
#==================================================#
# RAG #
#==================================================#
# More info: https://www.librechat.ai/docs/configuration/rag_api
# RAG_OPENAI_BASEURL=
# RAG_OPENAI_API_KEY=
# EMBEDDINGS_PROVIDER=openai
# EMBEDDINGS_MODEL=text-embedding-3-small
#===================================================#
# User System #
#===================================================#
@@ -413,6 +427,18 @@ FIREBASE_APP_ID=
ALLOW_SHARED_LINKS=true
ALLOW_SHARED_LINKS_PUBLIC=true
#==============================#
# Static File Cache Control #
#==============================#
# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age
# NODE_ENV must be set to production for these to take effect
# STATIC_CACHE_MAX_AGE=172800
# STATIC_CACHE_S_MAX_AGE=86400
# If you have another service in front of your LibreChat doing compression, disable express based compression here
# DISABLE_COMPRESSION=true
#===================================================#
# UI #
#===================================================#

View File

@@ -12,6 +12,7 @@ module.exports = {
'plugin:react-hooks/recommended',
'plugin:jest/recommended',
'prettier',
'plugin:jsx-a11y/recommended',
],
ignorePatterns: [
'client/dist/**/*',
@@ -32,7 +33,7 @@ module.exports = {
jsx: true,
},
},
plugins: ['react', 'react-hooks', '@typescript-eslint', 'import'],
plugins: ['react', 'react-hooks', '@typescript-eslint', 'import', 'jsx-a11y'],
rules: {
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow' }],
@@ -65,6 +66,7 @@ module.exports = {
'no-restricted-syntax': 'off',
'react/prop-types': ['off'],
'react/display-name': ['off'],
'no-nested-ternary': 'error',
'no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
quotes: ['error', 'single'],
},
@@ -118,6 +120,8 @@ module.exports = {
],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-condition': 'warn',
'@typescript-eslint/strict-boolean-expressions': 'warn',
},
},
{

26
.github/workflows/a11y.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Lint for accessibility issues
on:
pull_request:
paths:
- 'client/src/**'
workflow_dispatch:
inputs:
run_workflow:
description: 'Set to true to run this workflow'
required: true
default: 'false'
jobs:
axe-linter:
runs-on: ubuntu-latest
if: >
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_workflow == 'true')
steps:
- uses: actions/checkout@v4
- uses: dequelabs/axe-linter-action@v1
with:
api_key: ${{ secrets.AXE_LINTER_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

41
.github/workflows/deploy-dev.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Update Test Server
on:
workflow_run:
workflows: ["Docker Dev Images Build"]
types:
- completed
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
if: |
github.repository == 'danny-avila/LibreChat' &&
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install SSH Key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
known_hosts: ${{ secrets.DO_KNOWN_HOSTS }}
- name: Run update script on DigitalOcean Droplet
env:
DO_HOST: ${{ secrets.DO_HOST }}
DO_USER: ${{ secrets.DO_USER }}
run: |
ssh -o StrictHostKeyChecking=no ${DO_USER}@${DO_HOST} << EOF
sudo -i -u danny bash << EEOF
cd ~/LibreChat && \
git fetch origin main && \
npm run update:deployed && \
git checkout do-deploy && \
git rebase main && \
npm run start:deployed && \
echo "Update completed. Application should be running now."
EEOF
EOF

35
.github/workflows/helmcharts.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Build Helm Charts on Tag
# The workflow is triggered when a tag is pushed
on:
push:
tags:
- "*"
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: helmchart
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch LibreChat (debug)",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/api/server/index.js",
"env": {
"NODE_ENV": "production"
},
"console": "integratedTerminal"
}
]
}

View File

@@ -1,4 +1,4 @@
# v0.7.3
# v0.7.5-rc1
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,43 +1,44 @@
# v0.7.3
# Dockerfile.multi
# v0.7.5-rc1
# Build API, Client and Data Provider
# Base for all builds
FROM node:20-alpine AS base
WORKDIR /app
RUN apk --no-cache add curl
RUN npm config set fetch-retry-maxtimeout 600000 && \
npm config set fetch-retries 5 && \
npm config set fetch-retry-mintimeout 15000
COPY package*.json ./
COPY packages/data-provider/package*.json ./packages/data-provider/
COPY client/package*.json ./client/
COPY api/package*.json ./api/
RUN npm ci
# Build data-provider
FROM base AS data-provider-build
WORKDIR /app/packages/data-provider
COPY ./packages/data-provider ./
RUN npm install; npm cache clean --force
COPY packages/data-provider ./
RUN npm run build
RUN npm prune --production
# React client build
# Client build
FROM base AS client-build
WORKDIR /app/client
COPY ./client/package*.json ./
# Copy data-provider to client's node_modules
COPY --from=data-provider-build /app/packages/data-provider/ /app/client/node_modules/librechat-data-provider/
RUN npm install; npm cache clean --force
COPY ./client/ ./
COPY client ./
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
ENV NODE_OPTIONS="--max-old-space-size=2048"
RUN npm run build
RUN npm prune --production
# Node API setup
# API setup (including client dist)
FROM base AS api-build
WORKDIR /app
COPY api ./api
COPY config ./config
COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
COPY --from=client-build /app/client/dist ./client/dist
WORKDIR /app/api
COPY api/package*.json ./
COPY api/ ./
# Copy helper scripts
COPY config/ ./
# Copy data-provider to API's node_modules
COPY --from=data-provider-build /app/packages/data-provider/ /app/api/node_modules/librechat-data-provider/
RUN npm install --include prod; npm cache clean --force
COPY --from=client-build /app/client/dist /app/client/dist
RUN npm prune --production
EXPOSE 3080
ENV HOST=0.0.0.0
CMD ["node", "server/index.js"]
# Nginx setup
FROM nginx:1.21.1-alpine AS prod-stage
COPY ./client/nginx.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -81,7 +81,7 @@ LibreChat brings together the future of assistant AIs with the revolutionary tec
With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
[![Watch the video](https://img.youtube.com/vi/bSVHEbVPNl4/maxresdefault.jpg)](https://www.youtube.com/watch?v=bSVHEbVPNl4)
[![Watch the video](https://raw.githubusercontent.com/LibreChat-AI/librechat.ai/main/public/images/changelog/v0.7.4.png)](https://www.youtube.com/watch?v=cvosUxogdpI)
Click on the thumbnail to open the video☝
---

View File

@@ -12,12 +12,13 @@ const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const {
truncateText,
formatMessage,
addCacheControl,
titleFunctionPrompt,
parseParamFromPrompt,
createContextHandlers,
} = require('./prompts');
const spendTokens = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getModelMaxTokens, matchModelName } = require('~/utils');
const { sleep } = require('~/server/utils');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
@@ -32,6 +33,7 @@ function delayBeforeRetry(attempts, baseDelay = 1000) {
return new Promise((resolve) => setTimeout(resolve, baseDelay * attempts));
}
const tokenEventTypes = new Set(['message_start', 'message_delta']);
const { legacy } = anthropicSettings;
class AnthropicClient extends BaseClient {
@@ -44,6 +46,24 @@ class AnthropicClient extends BaseClient {
? options.contextStrategy.toLowerCase()
: 'discard';
this.setOptions(options);
/** @type {string | undefined} */
this.systemMessage;
/** @type {AnthropicMessageStartEvent| undefined} */
this.message_start;
/** @type {AnthropicMessageDeltaEvent| undefined} */
this.message_delta;
/** Whether the model is part of the Claude 3 Family
* @type {boolean} */
this.isClaude3;
/** Whether to use Messages API or Completions API
* @type {boolean} */
this.useMessages;
/** Whether or not the model is limited to the legacy amount of output tokens
* @type {boolean} */
this.isLegacyOutput;
/** Whether or not the model supports Prompt Caching
* @type {boolean} */
this.supportsCacheControl;
}
setOptions(options) {
@@ -63,14 +83,19 @@ class AnthropicClient extends BaseClient {
this.options = options;
}
const modelOptions = this.options.modelOptions || {};
this.modelOptions = {
...modelOptions,
model: modelOptions.model || anthropicSettings.model.default,
};
this.modelOptions = Object.assign(
{
model: anthropicSettings.model.default,
},
this.modelOptions,
this.options.modelOptions,
);
this.isClaude3 = this.modelOptions.model.includes('claude-3');
this.isLegacyOutput = !this.modelOptions.model.includes('claude-3-5-sonnet');
const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic);
this.isClaude3 = modelMatch.startsWith('claude-3');
this.isLegacyOutput = !modelMatch.startsWith('claude-3-5-sonnet');
this.supportsCacheControl =
this.options.promptCache && this.checkPromptCacheSupport(modelMatch);
if (
this.isLegacyOutput &&
@@ -147,19 +172,74 @@ class AnthropicClient extends BaseClient {
options.baseURL = this.options.reverseProxyUrl;
}
if (requestOptions?.model && requestOptions.model.includes('claude-3-5-sonnet')) {
if (
this.supportsCacheControl &&
requestOptions?.model &&
requestOptions.model.includes('claude-3-5-sonnet')
) {
options.defaultHeaders = {
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
};
} else if (this.supportsCacheControl) {
options.defaultHeaders = {
'anthropic-beta': 'prompt-caching-2024-07-31',
};
}
return new Anthropic(options);
}
getTokenCountForResponse(response) {
/**
* Get stream usage as returned by this client's API response.
* @returns {AnthropicStreamUsage} The stream usage object.
*/
getStreamUsage() {
const inputUsage = this.message_start?.message?.usage ?? {};
const outputUsage = this.message_delta?.usage ?? {};
return Object.assign({}, inputUsage, outputUsage);
}
/**
* Calculates the correct token count for the current message based on the token count map and API usage.
* Edge case: If the calculation results in a negative value, it returns the original estimate.
* If revisiting a conversation with a chat history entirely composed of token estimates,
* the cumulative token count going forward should become more accurate as the conversation progresses.
* @param {Object} params - The parameters for the calculation.
* @param {Record<string, number>} params.tokenCountMap - A map of message IDs to their token counts.
* @param {string} params.currentMessageId - The ID of the current message to calculate.
* @param {AnthropicStreamUsage} params.usage - The usage object returned by the API.
* @returns {number} The correct token count for the current message.
*/
calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage }) {
const originalEstimate = tokenCountMap[currentMessageId] || 0;
if (!usage || typeof usage.input_tokens !== 'number') {
return originalEstimate;
}
tokenCountMap[currentMessageId] = 0;
const totalTokensFromMap = Object.values(tokenCountMap).reduce((sum, count) => {
const numCount = Number(count);
return sum + (isNaN(numCount) ? 0 : numCount);
}, 0);
const totalInputTokens =
(usage.input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0);
const currentMessageTokens = totalInputTokens - totalTokensFromMap;
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
}
/**
* Get Token Count for LibreChat Message
* @param {TMessage} responseMessage
* @returns {number}
*/
getTokenCountForResponse(responseMessage) {
return this.getTokenCountForMessage({
role: 'assistant',
content: response.text,
content: responseMessage.text,
});
}
@@ -212,7 +292,38 @@ class AnthropicClient extends BaseClient {
return files;
}
async recordTokenUsage({ promptTokens, completionTokens, model, context = 'message' }) {
/**
* @param {object} params
* @param {number} params.promptTokens
* @param {number} params.completionTokens
* @param {AnthropicStreamUsage} [params.usage]
* @param {string} [params.model]
* @param {string} [params.context='message']
* @returns {Promise<void>}
*/
async recordTokenUsage({ promptTokens, completionTokens, usage, model, context = 'message' }) {
if (usage != null && usage?.input_tokens != null) {
const input = usage.input_tokens ?? 0;
const write = usage.cache_creation_input_tokens ?? 0;
const read = usage.cache_read_input_tokens ?? 0;
await spendStructuredTokens(
{
context,
user: this.user,
conversationId: this.conversationId,
model: model ?? this.modelOptions.model,
endpointTokenConfig: this.options.endpointTokenConfig,
},
{
promptTokens: { input, write, read },
completionTokens,
},
);
return;
}
await spendTokens(
{
context,
@@ -381,7 +492,10 @@ class AnthropicClient extends BaseClient {
identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`;
}
let promptPrefix = (this.options.promptPrefix || '').trim();
let promptPrefix = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
if (promptPrefix) {
// If the prompt prefix doesn't end with the end token, add it.
if (!promptPrefix.endsWith(`${this.endToken}`)) {
@@ -560,6 +674,18 @@ class AnthropicClient extends BaseClient {
: await client.completions.create(options);
}
/**
* @param {string} modelName
* @returns {boolean}
*/
checkPromptCacheSupport(modelName) {
const modelMatch = matchModelName(modelName, EModelEndpoint.anthropic);
if (modelMatch === 'claude-3-5-sonnet' || modelMatch === 'claude-3-haiku') {
return true;
}
return false;
}
async sendCompletion(payload, { onProgress, abortController }) {
if (!abortController) {
abortController = new AbortController();
@@ -606,10 +732,22 @@ class AnthropicClient extends BaseClient {
requestOptions.max_tokens_to_sample = maxOutputTokens || 1500;
}
if (this.systemMessage) {
if (this.systemMessage && this.supportsCacheControl === true) {
requestOptions.system = [
{
type: 'text',
text: this.systemMessage,
cache_control: { type: 'ephemeral' },
},
];
} else if (this.systemMessage) {
requestOptions.system = this.systemMessage;
}
if (this.supportsCacheControl === true && this.useMessages) {
requestOptions.messages = addCacheControl(requestOptions.messages);
}
logger.debug('[AnthropicClient]', { ...requestOptions });
const handleChunk = (currentChunk) => {
@@ -639,6 +777,11 @@ class AnthropicClient extends BaseClient {
for await (const completion of response) {
// Handle each completion as before
const type = completion?.type ?? '';
if (tokenEventTypes.has(type)) {
logger.debug(`[AnthropicClient] ${type}`, completion);
this[type] = completion;
}
if (completion?.delta?.text) {
handleChunk(completion.delta.text);
} else if (completion.completion) {
@@ -680,8 +823,10 @@ class AnthropicClient extends BaseClient {
getSaveOptions() {
return {
maxContextTokens: this.options.maxContextTokens,
artifacts: this.options.artifacts,
promptPrefix: this.options.promptPrefix,
modelLabel: this.options.modelLabel,
promptCache: this.options.promptCache,
resendFiles: this.options.resendFiles,
iconURL: this.options.iconURL,
greeting: this.options.greeting,
@@ -727,6 +872,8 @@ class AnthropicClient extends BaseClient {
*/
async titleConvo({ text, responseText = '' }) {
let title = 'New Chat';
this.message_delta = undefined;
this.message_start = undefined;
const convo = `<initial_message>
${truncateText(text)}
</initial_message>

View File

@@ -54,10 +54,22 @@ class BaseClient {
throw new Error('Subclasses attempted to call summarizeMessages without implementing it');
}
async getTokenCountForResponse(response) {
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', response);
/**
* Abstract method to get the token count for a message. Subclasses must implement this method.
* @param {TMessage} responseMessage
* @returns {number}
*/
getTokenCountForResponse(responseMessage) {
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', responseMessage);
}
/**
* Abstract method to record token usage. Subclasses must implement this method.
* If a correction to the token usage is needed, the method should return an object with the corrected token counts.
* @param {number} promptTokens
* @param {number} completionTokens
* @returns {Promise<void>}
*/
async recordTokenUsage({ promptTokens, completionTokens }) {
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', {
promptTokens,
@@ -536,13 +548,31 @@ class BaseClient {
this.getTokenCountForResponse &&
this.getTokenCount
) {
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
const completionTokens = this.getTokenCount(completion);
await this.recordTokenUsage({ promptTokens, completionTokens });
let completionTokens;
/**
* Metadata about input/output costs for the current message. The client
* should provide a function to get the current stream usage metadata; if not,
* use the legacy token estimations.
* @type {StreamUsage | null} */
const usage = this.getStreamUsage != null ? this.getStreamUsage() : null;
if (usage != null && Number(usage.output_tokens) > 0) {
responseMessage.tokenCount = usage.output_tokens;
completionTokens = responseMessage.tokenCount;
await this.updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts });
} else {
responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage);
completionTokens = this.getTokenCount(completion);
}
await this.recordTokenUsage({ promptTokens, completionTokens, usage });
}
if (this.userMessagePromise) {
await this.userMessagePromise;
}
this.responsePromise = this.saveMessageToDatabase(responseMessage, saveOptions, user);
const messageCache = getLogStores(CacheKeys.MESSAGES);
messageCache.set(
@@ -557,6 +587,66 @@ class BaseClient {
return responseMessage;
}
/**
* Stream usage should only be used for user message token count re-calculation if:
* - The stream usage is available, with input tokens greater than 0,
* - the client provides a function to calculate the current token count,
* - files are being resent with every message (default behavior; or if `false`, with no attachments),
* - the `promptPrefix` (custom instructions) is not set.
*
* In these cases, the legacy token estimations would be more accurate.
*
* TODO: included system messages in the `orderedMessages` accounting, potentially as a
* separate message in the UI. ChatGPT does this through "hidden" system messages.
* @param {object} params
* @param {StreamUsage} params.usage
* @param {Record<string, number>} params.tokenCountMap
* @param {TMessage} params.userMessage
* @param {object} params.opts
*/
async updateUserMessageTokenCount({ usage, tokenCountMap, userMessage, opts }) {
/** @type {boolean} */
const shouldUpdateCount =
this.calculateCurrentTokenCount != null &&
Number(usage.input_tokens) > 0 &&
(this.options.resendFiles ||
(!this.options.resendFiles && !this.options.attachments?.length)) &&
!this.options.promptPrefix;
if (!shouldUpdateCount) {
return;
}
const userMessageTokenCount = this.calculateCurrentTokenCount({
currentMessageId: userMessage.messageId,
tokenCountMap,
usage,
});
if (userMessageTokenCount === userMessage.tokenCount) {
return;
}
userMessage.tokenCount = userMessageTokenCount;
/*
Note: `AskController` saves the user message, so we update the count of its `userMessage` reference
*/
if (typeof opts?.getReqData === 'function') {
opts.getReqData({
userMessage,
});
}
/*
Note: we update the user message to be sure it gets the calculated token count;
though `AskController` saves the user message, EditController does not
*/
await this.userMessagePromise;
await this.updateMessageInDatabase({
messageId: userMessage.messageId,
tokenCount: userMessageTokenCount,
});
}
async loadHistory(conversationId, parentMessageId = null) {
logger.debug('[BaseClient] Loading history:', { conversationId, parentMessageId });
@@ -644,6 +734,10 @@ class BaseClient {
return { message: savedMessage, conversation };
}
/**
* Update a message in the database.
* @param {Partial<TMessage>} message
*/
async updateMessageInDatabase(message) {
await updateMessage(this.options.req, message);
}

View File

@@ -120,19 +120,7 @@ class GoogleClient extends BaseClient {
.filter((ex) => ex)
.filter((obj) => obj.input.content !== '' && obj.output.content !== '');
const modelOptions = this.options.modelOptions || {};
this.modelOptions = {
...modelOptions,
// set some good defaults (check for undefined in some cases because they may be 0)
model: modelOptions.model || settings.model.default,
temperature:
typeof modelOptions.temperature === 'undefined'
? settings.temperature.default
: modelOptions.temperature,
topP: typeof modelOptions.topP === 'undefined' ? settings.topP.default : modelOptions.topP,
topK: typeof modelOptions.topK === 'undefined' ? settings.topK.default : modelOptions.topK,
// stop: modelOptions.stop // no stop method for now
};
this.modelOptions = this.options.modelOptions || {};
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
@@ -402,8 +390,13 @@ class GoogleClient extends BaseClient {
parameters: this.modelOptions,
};
if (this.options.promptPrefix) {
payload.instances[0].context = this.options.promptPrefix;
let promptPrefix = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
if (promptPrefix) {
payload.instances[0].context = promptPrefix;
}
if (this.options.examples.length > 0) {
@@ -457,7 +450,10 @@ class GoogleClient extends BaseClient {
identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`;
}
let promptPrefix = (this.options.promptPrefix || '').trim();
let promptPrefix = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
if (promptPrefix) {
// If the prompt prefix doesn't end with the end token, add it.
if (!promptPrefix.endsWith(`${this.endToken}`)) {
@@ -677,24 +673,27 @@ class GoogleClient extends BaseClient {
const modelName = clientOptions.modelName ?? clientOptions.model ?? '';
if (modelName?.includes('1.5') && !this.project_id) {
/** @type {GenerativeModel} */
const client = model;
const requestOptions = {
contents: _payload,
};
let promptPrefix = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
if (this.options?.promptPrefix?.length) {
requestOptions.systemInstruction = {
parts: [
{
text: this.options.promptPrefix,
text: promptPrefix,
},
],
};
}
const safetySettings = _payload.safetySettings;
requestOptions.safetySettings = safetySettings;
requestOptions.safetySettings = _payload.safetySettings;
const delay = modelName.includes('flash') ? 8 : 14;
const result = await client.generateContentStream(requestOptions);
@@ -709,11 +708,10 @@ class GoogleClient extends BaseClient {
return reply;
}
const safetySettings = _payload.safetySettings;
const stream = await model.stream(messages, {
signal: abortController.signal,
timeout: 7000,
safetySettings: safetySettings,
safetySettings: _payload.safetySettings,
});
let delay = this.options.streamRate || 8;
@@ -782,11 +780,16 @@ class GoogleClient extends BaseClient {
contents: _payload,
};
let promptPrefix = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
if (this.options?.promptPrefix?.length) {
requestOptions.systemInstruction = {
parts: [
{
text: this.options.promptPrefix,
text: promptPrefix,
},
],
};
@@ -811,7 +814,7 @@ class GoogleClient extends BaseClient {
});
reply = titleResponse.content;
// TODO: RECORD TOKEN USAGE
return reply;
}
}
@@ -857,6 +860,7 @@ class GoogleClient extends BaseClient {
getSaveOptions() {
return {
artifacts: this.options.artifacts,
promptPrefix: this.options.promptPrefix,
modelLabel: this.options.modelLabel,
iconURL: this.options.iconURL,
@@ -871,38 +875,36 @@ class GoogleClient extends BaseClient {
}
async sendCompletion(payload, opts = {}) {
const modelName = payload.parameters?.model;
if (modelName && modelName.toLowerCase().includes('gemini')) {
const safetySettings = [
{
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold:
process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HATE_SPEECH',
threshold: process.env.GOOGLE_SAFETY_HATE_SPEECH || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HARASSMENT',
threshold: process.env.GOOGLE_SAFETY_HARASSMENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold:
process.env.GOOGLE_SAFETY_DANGEROUS_CONTENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
];
payload.safetySettings = safetySettings;
}
payload.safetySettings = this.getSafetySettings();
let reply = '';
reply = await this.getCompletion(payload, opts);
return reply.trim();
}
getSafetySettings() {
return [
{
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold:
process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HATE_SPEECH',
threshold: process.env.GOOGLE_SAFETY_HATE_SPEECH || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_HARASSMENT',
threshold: process.env.GOOGLE_SAFETY_HARASSMENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
{
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold:
process.env.GOOGLE_SAFETY_DANGEROUS_CONTENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
},
];
}
/* TO-DO: Handle tokens with Google tokenization NOTE: these are required */
static getTokenizer(encoding, isModelName = false, extendSpecialTokens = {}) {
if (tokenizersCache[encoding]) {

View File

@@ -6,6 +6,7 @@ const {
ImageDetail,
EModelEndpoint,
resolveHeaders,
openAISettings,
ImageDetailCost,
CohereConstants,
getResponseSender,
@@ -27,9 +28,9 @@ const {
createContextHandlers,
} = require('./prompts');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { spendTokens } = require('~/models/spendTokens');
const { isEnabled, sleep } = require('~/server/utils');
const { handleOpenAIErrors } = require('./tools/util');
const spendTokens = require('~/models/spendTokens');
const { createLLM, RunManager } = require('./llm');
const ChatGPTClient = require('./ChatGPTClient');
const { summaryBuffer } = require('./memory');
@@ -85,26 +86,13 @@ class OpenAIClient extends BaseClient {
this.apiKey = this.options.openaiApiKey;
}
const modelOptions = this.options.modelOptions || {};
if (!this.modelOptions) {
this.modelOptions = {
...modelOptions,
model: modelOptions.model || 'gpt-3.5-turbo',
temperature:
typeof modelOptions.temperature === 'undefined' ? 0.8 : modelOptions.temperature,
top_p: typeof modelOptions.top_p === 'undefined' ? 1 : modelOptions.top_p,
presence_penalty:
typeof modelOptions.presence_penalty === 'undefined' ? 1 : modelOptions.presence_penalty,
stop: modelOptions.stop,
};
} else {
// Update the modelOptions if it already exists
this.modelOptions = {
...this.modelOptions,
...modelOptions,
};
}
this.modelOptions = Object.assign(
{
model: openAISettings.model.default,
},
this.modelOptions,
this.options.modelOptions,
);
this.defaultVisionModel = this.options.visionModel ?? 'gpt-4-vision-preview';
if (typeof this.options.attachments?.then === 'function') {
@@ -413,6 +401,7 @@ class OpenAIClient extends BaseClient {
getSaveOptions() {
return {
artifacts: this.options.artifacts,
maxContextTokens: this.options.maxContextTokens,
chatGptLabel: this.options.chatGptLabel,
promptPrefix: this.options.promptPrefix,
@@ -475,6 +464,9 @@ class OpenAIClient extends BaseClient {
let promptTokens;
promptPrefix = (promptPrefix || this.options.promptPrefix || '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
if (this.options.attachments) {
const attachments = await this.options.attachments;
@@ -827,7 +819,7 @@ class OpenAIClient extends BaseClient {
const instructionsPayload = [
{
role: this.options.titleMessageRole ?? 'system',
role: this.options.titleMessageRole ?? (this.isOllama ? 'user' : 'system'),
content: `Please generate ${titleInstruction}
${convo}
@@ -1194,7 +1186,15 @@ ${convo}
}
let UnexpectedRoleError = false;
/** @type {Promise<void>} */
let streamPromise;
/** @type {(value: void | PromiseLike<void>) => void} */
let streamResolve;
if (modelOptions.stream) {
streamPromise = new Promise((resolve) => {
streamResolve = resolve;
});
const stream = await openai.beta.chat.completions
.stream({
...modelOptions,
@@ -1206,13 +1206,17 @@ ${convo}
.on('error', (err) => {
handleOpenAIErrors(err, errorCallback, 'stream');
})
.on('finalChatCompletion', (finalChatCompletion) => {
.on('finalChatCompletion', async (finalChatCompletion) => {
const finalMessage = finalChatCompletion?.choices?.[0]?.message;
if (finalMessage && finalMessage?.role !== 'assistant') {
if (!finalMessage) {
return;
}
await streamPromise;
if (finalMessage?.role !== 'assistant') {
finalChatCompletion.choices[0].message.role = 'assistant';
}
if (finalMessage && !finalMessage?.content?.trim()) {
if (typeof finalMessage.content !== 'string' || finalMessage.content.trim() === '') {
finalChatCompletion.choices[0].message.content = intermediateReply;
}
})
@@ -1235,6 +1239,8 @@ ${convo}
await sleep(streamRate);
}
streamResolve();
if (!UnexpectedRoleError) {
chatCompletion = await stream.finalChatCompletion().catch((err) => {
handleOpenAIErrors(err, errorCallback, 'finalChatCompletion');
@@ -1262,14 +1268,23 @@ ${convo}
throw new Error('Chat completion failed');
}
const { message, finish_reason } = chatCompletion.choices[0];
if (chatCompletion) {
this.metadata = { finish_reason };
const { choices } = chatCompletion;
if (!Array.isArray(choices) || choices.length === 0) {
logger.warn('[OpenAIClient] Chat completion response has no choices');
return intermediateReply;
}
const { message, finish_reason } = choices[0] ?? {};
this.metadata = { finish_reason };
logger.debug('[OpenAIClient] chatCompletion response', chatCompletion);
if (!message?.content?.trim() && intermediateReply.length) {
if (!message) {
logger.warn('[OpenAIClient] Message is undefined in chatCompletion response');
return intermediateReply;
}
if (typeof message.content !== 'string' || message.content.trim() === '') {
logger.debug(
'[OpenAIClient] chatCompletion: using intermediateReply due to empty message.content',
{ intermediateReply },

View File

@@ -42,6 +42,7 @@ class PluginsClient extends OpenAIClient {
getSaveOptions() {
return {
artifacts: this.options.artifacts,
chatGptLabel: this.options.chatGptLabel,
promptPrefix: this.options.promptPrefix,
tools: this.options.tools,
@@ -145,16 +146,22 @@ class PluginsClient extends OpenAIClient {
// initialize agent
const initializer = this.functionsAgent ? initializeFunctionsAgent : initializeCustomAgent;
let customInstructions = (this.options.promptPrefix ?? '').trim();
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
customInstructions = `${customInstructions ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
this.executor = await initializer({
model,
signal,
pastMessages,
tools: this.tools,
customInstructions,
verbose: this.options.debug,
returnIntermediateSteps: true,
customName: this.options.chatGptLabel,
currentDateString: this.currentDateString,
customInstructions: this.options.promptPrefix,
callbackManager: CallbackManager.fromHandlers({
async handleAgentAction(action, runId) {
handleAction(action, runId, onAgentAction);

View File

@@ -1,5 +1,5 @@
const { createStartHandler } = require('~/app/clients/callbacks');
const spendTokens = require('~/models/spendTokens');
const { spendTokens } = require('~/models/spendTokens');
const { logger } = require('~/config');
class RunManager {

View File

@@ -60,10 +60,10 @@ function addImages(intermediateSteps, responseMessage) {
if (!observation || !observation.includes('![')) {
return;
}
const observedImagePath = observation.match(/!\[.*\]\([^)]*\)/g);
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
responseMessage.text += '\n' + observation;
logger.debug('[addImages] added image from intermediateSteps:', observation);
responseMessage.text += '\n' + observedImagePath[0];
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
}
});
}

View File

@@ -81,4 +81,62 @@ describe('addImages', () => {
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(`${originalText}\n${imageMarkdown}`);
});
it('should extract only image markdowns when there is text between them', () => {
const markdownWithTextBetweenImages = `
![image1](/images/image1.png)
Some text between images that should not be included.
![image2](/images/image2.png)
More text that should be ignored.
![image3](/images/image3.png)
`;
intermediateSteps.push({ observation: markdownWithTextBetweenImages });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
it('should only return the first image when multiple images are present', () => {
const markdownWithMultipleImages = `
![image1](/images/image1.png)
![image2](/images/image2.png)
![image3](/images/image3.png)
`;
intermediateSteps.push({ observation: markdownWithMultipleImages });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
it('should not include any text or metadata surrounding the image markdown', () => {
const markdownWithMetadata = `
Title: Test Document
Author: John Doe
![image1](/images/image1.png)
Some content after the image.
Vector values: [0.1, 0.2, 0.3]
`;
intermediateSteps.push({ observation: markdownWithMetadata });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
it('should handle complex markdown with multiple images and only return the first one', () => {
const complexMarkdown = `
# Document Title
## Section 1
Here's some text with an embedded image:
![image1](/images/image1.png)
## Section 2
More text here...
![image2](/images/image2.png)
### Subsection
Even more content
![image3](/images/image3.png)
`;
intermediateSteps.push({ observation: complexMarkdown });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
});

View File

@@ -0,0 +1,43 @@
/**
* Anthropic API: Adds cache control to the appropriate user messages in the payload.
* @param {Array<AnthropicMessage>} messages - The array of message objects.
* @returns {Array<AnthropicMessage>} - The updated array of message objects with cache control added.
*/
function addCacheControl(messages) {
if (!Array.isArray(messages) || messages.length < 2) {
return messages;
}
const updatedMessages = [...messages];
let userMessagesModified = 0;
for (let i = updatedMessages.length - 1; i >= 0 && userMessagesModified < 2; i--) {
const message = updatedMessages[i];
if (message.role !== 'user') {
continue;
}
if (typeof message.content === 'string') {
message.content = [
{
type: 'text',
text: message.content,
cache_control: { type: 'ephemeral' },
},
];
userMessagesModified++;
} else if (Array.isArray(message.content)) {
for (let j = message.content.length - 1; j >= 0; j--) {
if (message.content[j].type === 'text') {
message.content[j].cache_control = { type: 'ephemeral' };
userMessagesModified++;
break;
}
}
}
}
return updatedMessages;
}
module.exports = addCacheControl;

View File

@@ -0,0 +1,227 @@
const addCacheControl = require('./addCacheControl');
describe('addCacheControl', () => {
test('should add cache control to the last two user messages with array content', () => {
const messages = [
{ role: 'user', content: [{ type: 'text', text: 'Hello' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] },
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'I\'m doing well, thanks!' }] },
{ role: 'user', content: [{ type: 'text', text: 'Great!' }] },
];
const result = addCacheControl(messages);
expect(result[0].content[0]).not.toHaveProperty('cache_control');
expect(result[2].content[0].cache_control).toEqual({ type: 'ephemeral' });
expect(result[4].content[0].cache_control).toEqual({ type: 'ephemeral' });
});
test('should add cache control to the last two user messages with string content', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
{ role: 'assistant', content: 'I\'m doing well, thanks!' },
{ role: 'user', content: 'Great!' },
];
const result = addCacheControl(messages);
expect(result[0].content).toBe('Hello');
expect(result[2].content[0]).toEqual({
type: 'text',
text: 'How are you?',
cache_control: { type: 'ephemeral' },
});
expect(result[4].content[0]).toEqual({
type: 'text',
text: 'Great!',
cache_control: { type: 'ephemeral' },
});
});
test('should handle mixed string and array content', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
];
const result = addCacheControl(messages);
expect(result[0].content[0]).toEqual({
type: 'text',
text: 'Hello',
cache_control: { type: 'ephemeral' },
});
expect(result[2].content[0].cache_control).toEqual({ type: 'ephemeral' });
});
test('should handle less than two user messages', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
];
const result = addCacheControl(messages);
expect(result[0].content[0]).toEqual({
type: 'text',
text: 'Hello',
cache_control: { type: 'ephemeral' },
});
expect(result[1].content).toBe('Hi there');
});
test('should return original array if no user messages', () => {
const messages = [
{ role: 'assistant', content: 'Hi there' },
{ role: 'assistant', content: 'How can I help?' },
];
const result = addCacheControl(messages);
expect(result).toEqual(messages);
});
test('should handle empty array', () => {
const messages = [];
const result = addCacheControl(messages);
expect(result).toEqual([]);
});
test('should handle non-array input', () => {
const messages = 'not an array';
const result = addCacheControl(messages);
expect(result).toBe('not an array');
});
test('should not modify assistant messages', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
];
const result = addCacheControl(messages);
expect(result[1].content).toBe('Hi there');
});
test('should handle multiple content items in user messages', () => {
const messages = [
{
role: 'user',
content: [
{ type: 'text', text: 'Hello' },
{ type: 'image', url: 'http://example.com/image.jpg' },
{ type: 'text', text: 'This is an image' },
],
},
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: 'How are you?' },
];
const result = addCacheControl(messages);
expect(result[0].content[0]).not.toHaveProperty('cache_control');
expect(result[0].content[1]).not.toHaveProperty('cache_control');
expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' });
expect(result[2].content[0]).toEqual({
type: 'text',
text: 'How are you?',
cache_control: { type: 'ephemeral' },
});
});
test('should handle an array with mixed content types', () => {
const messages = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }] },
{ role: 'assistant', content: 'I\'m doing well, thanks!' },
{ role: 'user', content: 'Great!' },
];
const result = addCacheControl(messages);
expect(result[0].content).toEqual('Hello');
expect(result[2].content[0]).toEqual({
type: 'text',
text: 'How are you?',
cache_control: { type: 'ephemeral' },
});
expect(result[4].content).toEqual([
{
type: 'text',
text: 'Great!',
cache_control: { type: 'ephemeral' },
},
]);
expect(result[1].content).toBe('Hi there');
expect(result[3].content).toBe('I\'m doing well, thanks!');
});
test('should handle edge case with multiple content types', () => {
const messages = [
{
role: 'user',
content: [
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'some_base64_string' },
},
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'another_base64_string' },
},
{ type: 'text', text: 'what do all these images have in common' },
],
},
{ role: 'assistant', content: 'I see multiple images.' },
{ role: 'user', content: 'Correct!' },
];
const result = addCacheControl(messages);
expect(result[0].content[0]).not.toHaveProperty('cache_control');
expect(result[0].content[1]).not.toHaveProperty('cache_control');
expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' });
expect(result[2].content[0]).toEqual({
type: 'text',
text: 'Correct!',
cache_control: { type: 'ephemeral' },
});
});
test('should handle user message with no text block', () => {
const messages = [
{
role: 'user',
content: [
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'some_base64_string' },
},
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'another_base64_string' },
},
],
},
{ role: 'assistant', content: 'I see two images.' },
{ role: 'user', content: 'Correct!' },
];
const result = addCacheControl(messages);
expect(result[0].content[0]).not.toHaveProperty('cache_control');
expect(result[0].content[1]).not.toHaveProperty('cache_control');
expect(result[2].content[0]).toEqual({
type: 'text',
text: 'Correct!',
cache_control: { type: 'ephemeral' },
});
});
});

View File

@@ -0,0 +1,527 @@
const dedent = require('dedent');
const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
const { components } = require('~/app/clients/prompts/shadcn-docs/components');
// eslint-disable-next-line no-unused-vars
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
# Good artifacts are...
- Substantial content (>15 lines)
- Content that the user is likely to modify, iterate on, or take ownership of
- Self-contained, complex content that can be understood on its own, without context from the conversation
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
- Content likely to be referenced or reused multiple times
# Don't use artifacts for...
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
- Suggestions, commentary, or feedback on existing artifacts
- Conversational or explanatory content that doesn't represent a standalone piece of work
- Content that is dependent on the current conversational context to be useful
- Content that is unlikely to be modified or iterated upon by the user
- Request from users that appears to be a one-off question
# Usage notes
- One artifact per message unless specifically requested
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
- Always provide complete, specific, and fully functional content without any placeholders, ellipses, or 'remains the same' comments.
<artifact_instructions>
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
1. Create the artifact using the following format:
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
\`\`\`
Your artifact content here
\`\`\`
:::
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
3. Include a \`title\` attribute to provide a brief title or description of the content.
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
- The lucide-react@0.263.1 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
</artifact_instructions>
Here are some examples of correct usage of artifacts:
<examples>
<example_docstring>
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
</example_docstring>
<example>
<user_query>Can you create a simple flow chart showing the process of making tea using Mermaid?</user_query>
<assistant_response>
Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
\`\`\`mermaid
graph TD
A[Start] --> B{Water boiled?}
B -->|Yes| C[Add tea leaves to cup]
B -->|No| D[Boil water]
D --> B
C --> E[Pour boiling water into cup]
E --> F[Steep tea for desired time]
F --> G[Remove tea leaves]
G --> H[Add milk or sugar, if desired]
H --> I[Enjoy your tea!]
I --> J[End]
\`\`\`
:::
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
1. Start
2. Check if water is boiled
3. If not boiled, boil the water
4. Once water is boiled, add tea leaves to the cup
5. Pour boiling water into the cup
6. Steep the tea for the desired time
7. Remove the tea leaves
8. Optionally add milk or sugar
9. Enjoy your tea!
10. End
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
</assistant_response>
</example>
</examples>`;
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
# Good artifacts are...
- Substantial content (>15 lines)
- Content that the user is likely to modify, iterate on, or take ownership of
- Self-contained, complex content that can be understood on its own, without context from the conversation
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
- Content likely to be referenced or reused multiple times
# Don't use artifacts for...
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
- Suggestions, commentary, or feedback on existing artifacts
- Conversational or explanatory content that doesn't represent a standalone piece of work
- Content that is dependent on the current conversational context to be useful
- Content that is unlikely to be modified or iterated upon by the user
- Request from users that appears to be a one-off question
# Usage notes
- One artifact per message unless specifically requested
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
<artifact_instructions>
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
1. Create the artifact using the following format:
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
\`\`\`
Your artifact content here
\`\`\`
:::
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
3. Include a \`title\` attribute to provide a brief title or description of the content.
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
- The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
- The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
- The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
- The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
</artifact_instructions>
Here are some examples of correct usage of artifacts:
<examples>
<example_docstring>
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
</example_docstring>
<example>
<user_query>Can you create a simple flow chart showing the process of making tea using Mermaid?</user_query>
<assistant_response>
Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
\`\`\`mermaid
graph TD
A[Start] --> B{Water boiled?}
B -->|Yes| C[Add tea leaves to cup]
B -->|No| D[Boil water]
D --> B
C --> E[Pour boiling water into cup]
E --> F[Steep tea for desired time]
F --> G[Remove tea leaves]
G --> H[Add milk or sugar, if desired]
H --> I[Enjoy your tea!]
I --> J[End]
\`\`\`
:::
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
1. Start
2. Check if water is boiled
3. If not boiled, boil the water
4. Once water is boiled, add tea leaves to the cup
5. Pour boiling water into the cup
6. Steep the tea for the desired time
7. Remove the tea leaves
8. Optionally add milk or sugar
9. Enjoy your tea!
10. End
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
</assistant_response>
</example>
<example>
<user_query>Create a simple React counter component</user_query>
<assistant_response>
Here's a simple React counter component:
:::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
\`\`\`
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div className="p-4">
<p className="mb-2">Count: {count}</p>
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
\`\`\`
:::
This component creates a simple counter with an increment button.
</assistant_response>
</example>
<example>
<user_query>Create a basic HTML structure for a blog post</user_query>
<assistant_response>
Here's a basic HTML structure for a blog post:
:::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
\`\`\`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Blog Post</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
h1 { color: #333; }
p { margin-bottom: 15px; }
</style>
</head>
<body>
<header>
<h1>My First Blog Post</h1>
</header>
<main>
<article>
<p>This is the content of my blog post. It's short and sweet!</p>
</article>
</main>
<footer>
<p>&copy; 2023 My Blog</p>
</footer>
</body>
</html>
\`\`\`
:::
This HTML structure provides a simple layout for a blog post.
</assistant_response>
</example>
</examples>`;
const artifactsOpenAIPrompt = dedent`The assistant can create and reference artifacts during conversations.
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
# Good artifacts are...
- Substantial content (>15 lines)
- Content that the user is likely to modify, iterate on, or take ownership of
- Self-contained, complex content that can be understood on its own, without context from the conversation
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
- Content likely to be referenced or reused multiple times
# Don't use artifacts for...
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
- Suggestions, commentary, or feedback on existing artifacts
- Conversational or explanatory content that doesn't represent a standalone piece of work
- Content that is dependent on the current conversational context to be useful
- Content that is unlikely to be modified or iterated upon by the user
- Request from users that appears to be a one-off question
# Usage notes
- One artifact per message unless specifically requested
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
## Artifact Instructions
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
1. Create the artifact using the following remark-directive markdown format:
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
\`\`\`
Your artifact content here
\`\`\`
:::
a. Example of correct format:
:::artifact{identifier="example-artifact" type="text/plain" title="Example Artifact"}
\`\`\`
This is the content of the artifact.
It can span multiple lines.
\`\`\`
:::
b. Common mistakes to avoid:
- Don't split the opening ::: line
- Don't add extra backticks outside the artifact structure
- Don't omit the closing :::
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
3. Include a \`title\` attribute to provide a brief title or description of the content.
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
- HTML: "text/html"
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
- The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
- The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
- The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
- The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
7. NEVER use triple backticks to enclose the artifact, ONLY the content within the artifact.
Here are some examples of correct usage of artifacts:
## Examples
### Example 1
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
User: Can you create a simple flow chart showing the process of making tea using Mermaid?
Assistant: Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
\`\`\`mermaid
graph TD
A[Start] --> B{Water boiled?}
B -->|Yes| C[Add tea leaves to cup]
B -->|No| D[Boil water]
D --> B
C --> E[Pour boiling water into cup]
E --> F[Steep tea for desired time]
F --> G[Remove tea leaves]
G --> H[Add milk or sugar, if desired]
H --> I[Enjoy your tea!]
I --> J[End]
\`\`\`
:::
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
1. Start
2. Check if water is boiled
3. If not boiled, boil the water
4. Once water is boiled, add tea leaves to the cup
5. Pour boiling water into the cup
6. Steep the tea for the desired time
7. Remove the tea leaves
8. Optionally add milk or sugar
9. Enjoy your tea!
10. End
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
---
### Example 2
User: Create a simple React counter component
Assistant: Here's a simple React counter component:
:::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
\`\`\`
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div className="p-4">
<p className="mb-2">Count: {count}</p>
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
\`\`\`
:::
This component creates a simple counter with an increment button.
---
### Example 3
User: Create a basic HTML structure for a blog post
Assistant: Here's a basic HTML structure for a blog post:
:::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
\`\`\`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Blog Post</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
h1 { color: #333; }
p { margin-bottom: 15px; }
</style>
</head>
<body>
<header>
<h1>My First Blog Post</h1>
</header>
<main>
<article>
<p>This is the content of my blog post. It's short and sweet!</p>
</article>
</main>
<footer>
<p>&copy; 2023 My Blog</p>
</footer>
</body>
</html>
\`\`\`
:::
This HTML structure provides a simple layout for a blog post.
---`;
/**
*
* @param {Object} params
* @param {EModelEndpoint | string} params.endpoint - The current endpoint
* @param {ArtifactModes} params.artifacts - The current artifact mode
* @returns
*/
const generateArtifactsPrompt = ({ endpoint, artifacts }) => {
if (artifacts === ArtifactModes.CUSTOM) {
return null;
}
let prompt = artifactsPrompt;
if (endpoint !== EModelEndpoint.anthropic) {
prompt = artifactsOpenAIPrompt;
}
if (artifacts === ArtifactModes.SHADCNUI) {
prompt += generateShadcnPrompt({ components, useXML: endpoint === EModelEndpoint.anthropic });
}
return prompt;
};
module.exports = generateArtifactsPrompt;

View File

@@ -1,3 +1,4 @@
const addCacheControl = require('./addCacheControl');
const formatMessages = require('./formatMessages');
const summaryPrompts = require('./summaryPrompts');
const handleInputs = require('./handleInputs');
@@ -8,6 +9,7 @@ const createVisionPrompt = require('./createVisionPrompt');
const createContextHandlers = require('./createContextHandlers');
module.exports = {
addCacheControl,
...formatMessages,
...summaryPrompts,
...handleInputs,

View File

@@ -0,0 +1,495 @@
// Essential Components
const essentialComponents = {
avatar: {
componentName: 'Avatar',
importDocs: 'import { Avatar, AvatarFallback, AvatarImage } from "/components/ui/avatar"',
usageDocs: `
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>`,
},
button: {
componentName: 'Button',
importDocs: 'import { Button } from "/components/ui/button"',
usageDocs: `
<Button variant="outline">Button</Button>`,
},
card: {
componentName: 'Card',
importDocs: `
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "/components/ui/card"`,
usageDocs: `
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent>
<p>Card Content</p>
</CardContent>
<CardFooter>
<p>Card Footer</p>
</CardFooter>
</Card>`,
},
checkbox: {
componentName: 'Checkbox',
importDocs: 'import { Checkbox } from "/components/ui/checkbox"',
usageDocs: '<Checkbox />',
},
input: {
componentName: 'Input',
importDocs: 'import { Input } from "/components/ui/input"',
usageDocs: '<Input />',
},
label: {
componentName: 'Label',
importDocs: 'import { Label } from "/components/ui/label"',
usageDocs: '<Label htmlFor="email">Your email address</Label>',
},
radioGroup: {
componentName: 'RadioGroup',
importDocs: `
import { Label } from "/components/ui/label"
import { RadioGroup, RadioGroupItem } from "/components/ui/radio-group"`,
usageDocs: `
<RadioGroup defaultValue="option-one">
<div className="flex items-center space-x-2">
<RadioGroupItem value="option-one" id="option-one" />
<Label htmlFor="option-one">Option One</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option-two" id="option-two" />
<Label htmlFor="option-two">Option Two</Label>
</div>
</RadioGroup>`,
},
select: {
componentName: 'Select',
importDocs: `
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "/components/ui/select"`,
usageDocs: `
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>`,
},
textarea: {
componentName: 'Textarea',
importDocs: 'import { Textarea } from "/components/ui/textarea"',
usageDocs: '<Textarea />',
},
};
// Extra Components
const extraComponents = {
accordion: {
componentName: 'Accordion',
importDocs: `
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "/components/ui/accordion"`,
usageDocs: `
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
</Accordion>`,
},
alertDialog: {
componentName: 'AlertDialog',
importDocs: `
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "/components/ui/alert-dialog"`,
usageDocs: `
<AlertDialog>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>`,
},
alert: {
componentName: 'Alert',
importDocs: `
import {
Alert,
AlertDescription,
AlertTitle,
} from "/components/ui/alert"`,
usageDocs: `
<Alert>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>
You can add components to your app using the cli.
</AlertDescription>
</Alert>`,
},
aspectRatio: {
componentName: 'AspectRatio',
importDocs: 'import { AspectRatio } from "/components/ui/aspect-ratio"',
usageDocs: `
<AspectRatio ratio={16 / 9}>
<Image src="..." alt="Image" className="rounded-md object-cover" />
</AspectRatio>`,
},
badge: {
componentName: 'Badge',
importDocs: 'import { Badge } from "/components/ui/badge"',
usageDocs: '<Badge>Badge</Badge>',
},
calendar: {
componentName: 'Calendar',
importDocs: 'import { Calendar } from "/components/ui/calendar"',
usageDocs: '<Calendar />',
},
carousel: {
componentName: 'Carousel',
importDocs: `
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "/components/ui/carousel"`,
usageDocs: `
<Carousel>
<CarouselContent>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>`,
},
collapsible: {
componentName: 'Collapsible',
importDocs: `
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "/components/ui/collapsible"`,
usageDocs: `
<Collapsible>
<CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
<CollapsibleContent>
Yes. Free to use for personal and commercial projects. No attribution required.
</CollapsibleContent>
</Collapsible>`,
},
dialog: {
componentName: 'Dialog',
importDocs: `
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "/components/ui/dialog"`,
usageDocs: `
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>`,
},
dropdownMenu: {
componentName: 'DropdownMenu',
importDocs: `
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "/components/ui/dropdown-menu"`,
usageDocs: `
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Team</DropdownMenuItem>
<DropdownMenuItem>Subscription</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>`,
},
menubar: {
componentName: 'Menubar',
importDocs: `
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarTrigger,
} from "/components/ui/menubar"`,
usageDocs: `
<Menubar>
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>
New Tab <MenubarShortcut>⌘T</MenubarShortcut>
</MenubarItem>
<MenubarItem>New Window</MenubarItem>
<MenubarSeparator />
<MenubarItem>Share</MenubarItem>
<MenubarSeparator />
<MenubarItem>Print</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>`,
},
navigationMenu: {
componentName: 'NavigationMenu',
importDocs: `
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "/components/ui/navigation-menu"`,
usageDocs: `
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Item One</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink>Link</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>`,
},
popover: {
componentName: 'Popover',
importDocs: `
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "/components/ui/popover"`,
usageDocs: `
<Popover>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>Place content for the popover here.</PopoverContent>
</Popover>`,
},
progress: {
componentName: 'Progress',
importDocs: 'import { Progress } from "/components/ui/progress"',
usageDocs: '<Progress value={33} />',
},
separator: {
componentName: 'Separator',
importDocs: 'import { Separator } from "/components/ui/separator"',
usageDocs: '<Separator />',
},
sheet: {
componentName: 'Sheet',
importDocs: `
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "/components/ui/sheet"`,
usageDocs: `
<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Are you sure absolutely sure?</SheetTitle>
<SheetDescription>
This action cannot be undone.
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>`,
},
skeleton: {
componentName: 'Skeleton',
importDocs: 'import { Skeleton } from "/components/ui/skeleton"',
usageDocs: '<Skeleton className="w-[100px] h-[20px] rounded-full" />',
},
slider: {
componentName: 'Slider',
importDocs: 'import { Slider } from "/components/ui/slider"',
usageDocs: '<Slider defaultValue={[33]} max={100} step={1} />',
},
switch: {
componentName: 'Switch',
importDocs: 'import { Switch } from "/components/ui/switch"',
usageDocs: '<Switch />',
},
table: {
componentName: 'Table',
importDocs: `
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "/components/ui/table"`,
usageDocs: `
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">INV001</TableCell>
<TableCell>Paid</TableCell>
<TableCell>Credit Card</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableBody>
</Table>`,
},
tabs: {
componentName: 'Tabs',
importDocs: `
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "/components/ui/tabs"`,
usageDocs: `
<Tabs defaultValue="account" className="w-[400px]">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Make changes to your account here.</TabsContent>
<TabsContent value="password">Change your password here.</TabsContent>
</Tabs>`,
},
toast: {
componentName: 'Toast',
importDocs: `
import { useToast } from "/components/ui/use-toast"
import { Button } from "/components/ui/button"`,
usageDocs: `
export function ToastDemo() {
const { toast } = useToast()
return (
<Button
onClick={() => {
toast({
title: "Scheduled: Catch up",
description: "Friday, February 10, 2023 at 5:57 PM",
})
}}
>
Show Toast
</Button>
)
}`,
},
toggle: {
componentName: 'Toggle',
importDocs: 'import { Toggle } from "/components/ui/toggle"',
usageDocs: '<Toggle>Toggle</Toggle>',
},
tooltip: {
componentName: 'Tooltip',
importDocs: `
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "/components/ui/tooltip"`,
usageDocs: `
<TooltipProvider>
<Tooltip>
<TooltipTrigger>Hover</TooltipTrigger>
<TooltipContent>
<p>Add to library</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>`,
},
};
const components = Object.assign({}, essentialComponents, extraComponents);
module.exports = {
components,
};

View File

@@ -0,0 +1,50 @@
const dedent = require('dedent');
/**
* Generate system prompt for AI-assisted React component creation
* @param {Object} options - Configuration options
* @param {Object} options.components - Documentation for shadcn components
* @param {boolean} [options.useXML=false] - Whether to use XML-style formatting for component instructions
* @returns {string} The generated system prompt
*/
function generateShadcnPrompt(options) {
const { components, useXML = false } = options;
let systemPrompt = dedent`
## Additional Artifact Instructions for React Components: "application/vnd.react"
There are some prestyled components (primitives) available for use. Please use your best judgement to use any of these components if the app calls for one.
Here are the components that are available, along with how to import them, and how to use them:
${Object.values(components)
.map((component) => {
if (useXML) {
return dedent`
<component>
<name>${component.componentName}</name>
<import-instructions>${component.importDocs}</import-instructions>
<usage-instructions>${component.usageDocs}</usage-instructions>
</component>
`;
} else {
return dedent`
# ${component.componentName}
## Import Instructions
${component.importDocs}
## Usage Instructions
${component.usageDocs}
`;
}
})
.join('\n\n')}
`;
return systemPrompt;
}
module.exports = {
generateShadcnPrompt,
};

View File

@@ -206,12 +206,26 @@ describe('AnthropicClient', () => {
const modelOptions = {
model: 'claude-3-5-sonnet-20240307',
};
client.setOptions({ modelOptions });
client.setOptions({ modelOptions, promptCache: true });
const anthropicClient = client.getClient(modelOptions);
expect(anthropicClient._options.defaultHeaders).toBeDefined();
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
'max-tokens-3-5-sonnet-2024-07-15',
'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31',
);
});
it('should add beta header for claude-3-haiku model', () => {
const client = new AnthropicClient('test-api-key');
const modelOptions = {
model: 'claude-3-haiku-2028',
};
client.setOptions({ modelOptions, promptCache: true });
const anthropicClient = client.getClient(modelOptions);
expect(anthropicClient._options.defaultHeaders).toBeDefined();
expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta');
expect(anthropicClient._options.defaultHeaders['anthropic-beta']).toBe(
'prompt-caching-2024-07-31',
);
});
@@ -226,4 +240,145 @@ describe('AnthropicClient', () => {
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
});
});
describe('calculateCurrentTokenCount', () => {
let client;
beforeEach(() => {
client = new AnthropicClient('test-api-key');
});
it('should calculate correct token count when usage is provided', () => {
const tokenCountMap = {
msg1: 10,
msg2: 20,
currentMsg: 30,
};
const currentMessageId = 'currentMsg';
const usage = {
input_tokens: 70,
output_tokens: 50,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(40); // 70 - (10 + 20) = 40
});
it('should return original estimate if calculation results in negative value', () => {
const tokenCountMap = {
msg1: 40,
msg2: 50,
currentMsg: 30,
};
const currentMessageId = 'currentMsg';
const usage = {
input_tokens: 80,
output_tokens: 50,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(30); // Original estimate
});
it('should handle cache creation and read input tokens', () => {
const tokenCountMap = {
msg1: 10,
msg2: 20,
currentMsg: 30,
};
const currentMessageId = 'currentMsg';
const usage = {
input_tokens: 50,
cache_creation_input_tokens: 10,
cache_read_input_tokens: 20,
output_tokens: 40,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(50); // (50 + 10 + 20) - (10 + 20) = 50
});
it('should handle missing usage properties', () => {
const tokenCountMap = {
msg1: 10,
msg2: 20,
currentMsg: 30,
};
const currentMessageId = 'currentMsg';
const usage = {
output_tokens: 40,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(30); // Original estimate
});
it('should handle empty tokenCountMap', () => {
const tokenCountMap = {};
const currentMessageId = 'currentMsg';
const usage = {
input_tokens: 50,
output_tokens: 40,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(50);
expect(Number.isNaN(result)).toBe(false);
});
it('should handle zero values in usage', () => {
const tokenCountMap = {
msg1: 10,
currentMsg: 20,
};
const currentMessageId = 'currentMsg';
const usage = {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 0,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(20); // Should return original estimate
expect(Number.isNaN(result)).toBe(false);
});
it('should handle undefined usage', () => {
const tokenCountMap = {
msg1: 10,
currentMsg: 20,
};
const currentMessageId = 'currentMsg';
const usage = undefined;
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(20); // Should return original estimate
expect(Number.isNaN(result)).toBe(false);
});
it('should handle non-numeric values in tokenCountMap', () => {
const tokenCountMap = {
msg1: 'ten',
currentMsg: 20,
};
const currentMessageId = 'currentMsg';
const usage = {
input_tokens: 30,
output_tokens: 10,
};
const result = client.calculateCurrentTokenCount({ tokenCountMap, currentMessageId, usage });
expect(result).toBe(30); // Should return 30 (input_tokens) - 0 (ignored 'ten') = 30
expect(Number.isNaN(result)).toBe(false);
});
});
});

View File

@@ -38,7 +38,12 @@ const run = async () => {
"On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire, that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish. In a free hour, when our power of choice is untrammelled and when nothing prevents our being able to do what we like best, every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business it will frequently occur that pleasures have to be repudiated and annoyances accepted. The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains."
`;
const model = 'gpt-3.5-turbo';
const maxContextTokens = model === 'gpt-4' ? 8191 : model === 'gpt-4-32k' ? 32767 : 4095; // 1 less than maximum
let maxContextTokens = 4095;
if (model === 'gpt-4') {
maxContextTokens = 8191;
} else if (model === 'gpt-4-32k') {
maxContextTokens = 32767;
}
const clientOptions = {
reverseProxyUrl: process.env.OPENAI_REVERSE_PROXY || null,
maxContextTokens,

View File

@@ -12,9 +12,15 @@ class GoogleSearchResults extends Tool {
this.envVarApiKey = 'GOOGLE_SEARCH_API_KEY';
this.envVarSearchEngineId = 'GOOGLE_CSE_ID';
this.override = fields.override ?? false;
this.apiKey = fields.apiKey ?? getEnvironmentVariable(this.envVarApiKey);
this.apiKey = fields[this.envVarApiKey] ?? getEnvironmentVariable(this.envVarApiKey);
this.searchEngineId =
fields.searchEngineId ?? getEnvironmentVariable(this.envVarSearchEngineId);
fields[this.envVarSearchEngineId] ?? getEnvironmentVariable(this.envVarSearchEngineId);
if (!this.override && (!this.apiKey || !this.searchEngineId)) {
throw new Error(
`Missing ${this.envVarApiKey} or ${this.envVarSearchEngineId} environment variable.`,
);
}
this.kwargs = fields?.kwargs ?? {};
this.name = 'google';

View File

@@ -12,7 +12,7 @@ class TavilySearchResults extends Tool {
this.envVar = 'TAVILY_API_KEY';
/* Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false;
this.apiKey = fields.apiKey ?? this.getApiKey();
this.apiKey = fields[this.envVar] ?? this.getApiKey();
this.kwargs = fields?.kwargs ?? {};
this.name = 'tavily_search_results_json';
@@ -82,7 +82,9 @@ class TavilySearchResults extends Tool {
const json = await response.json();
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}: ${json.error}`);
throw new Error(
`Request failed with status ${response.status}: ${json?.detail?.error || json?.error}`,
);
}
return JSON.stringify(json);

View File

@@ -0,0 +1,50 @@
const GoogleSearch = require('../GoogleSearch');
jest.mock('node-fetch');
jest.mock('@langchain/core/utils/env');
describe('GoogleSearch', () => {
let originalEnv;
const mockApiKey = 'mock_api';
const mockSearchEngineId = 'mock_search_engine_id';
beforeAll(() => {
originalEnv = { ...process.env };
});
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
GOOGLE_SEARCH_API_KEY: mockApiKey,
GOOGLE_CSE_ID: mockSearchEngineId,
};
});
afterEach(() => {
jest.clearAllMocks();
process.env = originalEnv;
});
it('should use mockApiKey and mockSearchEngineId when environment variables are not set', () => {
const instance = new GoogleSearch({
GOOGLE_SEARCH_API_KEY: mockApiKey,
GOOGLE_CSE_ID: mockSearchEngineId,
});
expect(instance.apiKey).toBe(mockApiKey);
expect(instance.searchEngineId).toBe(mockSearchEngineId);
});
it('should throw an error if GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID is missing', () => {
delete process.env.GOOGLE_SEARCH_API_KEY;
expect(() => new GoogleSearch()).toThrow(
'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
);
process.env.GOOGLE_SEARCH_API_KEY = mockApiKey;
delete process.env.GOOGLE_CSE_ID;
expect(() => new GoogleSearch()).toThrow(
'Missing GOOGLE_SEARCH_API_KEY or GOOGLE_CSE_ID environment variable.',
);
});
});

View File

@@ -0,0 +1,38 @@
const TavilySearchResults = require('../TavilySearchResults');
jest.mock('node-fetch');
jest.mock('@langchain/core/utils/env');
describe('TavilySearchResults', () => {
let originalEnv;
const mockApiKey = 'mock_api_key';
beforeAll(() => {
originalEnv = { ...process.env };
});
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
TAVILY_API_KEY: mockApiKey,
};
});
afterEach(() => {
jest.clearAllMocks();
process.env = originalEnv;
});
it('should throw an error if TAVILY_API_KEY is missing', () => {
delete process.env.TAVILY_API_KEY;
expect(() => new TavilySearchResults()).toThrow('Missing TAVILY_API_KEY environment variable.');
});
it('should use mockApiKey when TAVILY_API_KEY is not set in the environment', () => {
const instance = new TavilySearchResults({
TAVILY_API_KEY: mockApiKey,
});
expect(instance.apiKey).toBe(mockApiKey);
});
});

View File

@@ -35,7 +35,7 @@ const clearPendingReq = async ({ userId, cache: _cache }) => {
return;
}
const key = `${USE_REDIS ? namespace : ''}:${userId ?? ''}`;
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId ?? ''}`;
const currentReq = +((await cache.get(key)) ?? 0);
if (currentReq && currentReq >= 1) {

View File

@@ -35,11 +35,11 @@ const messages = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis, ttl: Time.FIVE_MINUTES })
: new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.FIVE_MINUTES });
const tokenConfig = isEnabled(USE_REDIS) // ttl: 30 minutes
const tokenConfig = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES })
: new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES });
const genTitle = isEnabled(USE_REDIS) // ttl: 2 minutes
const genTitle = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES })
: new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: Time.TWO_MINUTES });
@@ -69,6 +69,7 @@ const namespaces = {
registrations: createViolationInstance('registrations'),
[ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT),
[ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT),
[ViolationTypes.CONVO_ACCESS]: createViolationInstance(ViolationTypes.CONVO_ACCESS),
[ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT),
[ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT),
[ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance(

View File

@@ -2,6 +2,20 @@ const Conversation = require('./schema/convoSchema');
const { getMessages, deleteMessages } = require('./Message');
const logger = require('~/config/winston');
/**
* Searches for a conversation by conversationId and returns a lean document with only conversationId and user.
* @param {string} conversationId - The conversation's ID.
* @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found.
*/
const searchConversation = async (conversationId) => {
try {
return await Conversation.findOne({ conversationId }, 'conversationId user').lean();
} catch (error) {
logger.error('[searchConversation] Error searching conversation', error);
throw new Error('Error searching conversation');
}
};
/**
* Retrieves a single conversation for a given user and conversation ID.
* @param {string} user - The user's ID.
@@ -19,6 +33,7 @@ const getConvo = async (user, conversationId) => {
module.exports = {
Conversation,
searchConversation,
/**
* Saves a conversation to the database.
* @param {Object} req - The request object.

View File

@@ -1,268 +1,249 @@
//const crypto = require('crypto');
const logger = require('~/config/winston');
const Conversation = require('./schema/convoSchema');
const ConversationTag = require('./schema/conversationTagSchema');
const Conversation = require('./schema/convoSchema');
const logger = require('~/config/winston');
const SAVED_TAG = 'Saved';
const updateTagsForConversation = async (user, conversationId, tags) => {
/**
* Retrieves all conversation tags for a user.
* @param {string} user - The user ID.
* @returns {Promise<Array>} An array of conversation tags.
*/
const getConversationTags = async (user) => {
try {
const conversation = await Conversation.findOne({ user, conversationId });
if (!conversation) {
return { message: 'Conversation not found' };
}
const addedTags = tags.tags.filter((tag) => !conversation.tags.includes(tag));
const removedTags = conversation.tags.filter((tag) => !tags.tags.includes(tag));
for (const tag of addedTags) {
await ConversationTag.updateOne({ tag, user }, { $inc: { count: 1 } }, { upsert: true });
}
for (const tag of removedTags) {
await ConversationTag.updateOne({ tag, user }, { $inc: { count: -1 } });
}
conversation.tags = tags.tags;
await conversation.save({ timestamps: { updatedAt: false } });
return conversation.tags;
return await ConversationTag.find({ user }).sort({ position: 1 }).lean();
} catch (error) {
logger.error('[updateTagsToConversation] Error updating tags', error);
return { message: 'Error updating tags' };
logger.error('[getConversationTags] Error getting conversation tags', error);
throw new Error('Error getting conversation tags');
}
};
/**
* Creates a new conversation tag.
* @param {string} user - The user ID.
* @param {Object} data - The tag data.
* @param {string} data.tag - The tag name.
* @param {string} [data.description] - The tag description.
* @param {boolean} [data.addToConversation] - Whether to add the tag to a conversation.
* @param {string} [data.conversationId] - The conversation ID to add the tag to.
* @returns {Promise<Object>} The created tag.
*/
const createConversationTag = async (user, data) => {
try {
const cTag = await ConversationTag.findOne({ user, tag: data.tag });
if (cTag) {
return cTag;
const { tag, description, addToConversation, conversationId } = data;
const existingTag = await ConversationTag.findOne({ user, tag }).lean();
if (existingTag) {
return existingTag;
}
const addToConversation = data.addToConversation && data.conversationId;
const newTag = await ConversationTag.create({
user,
tag: data.tag,
count: 0,
description: data.description,
position: 1,
});
const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean();
const position = (maxPosition?.position || 0) + 1;
await ConversationTag.updateMany(
{ user, position: { $gte: 1 }, _id: { $ne: newTag._id } },
{ $inc: { position: 1 } },
const newTag = await ConversationTag.findOneAndUpdate(
{ tag, user },
{
tag,
user,
count: addToConversation ? 1 : 0,
position,
description,
$setOnInsert: { createdAt: new Date() },
},
{
new: true,
upsert: true,
lean: true,
},
);
if (addToConversation) {
const conversation = await Conversation.findOne({
user,
conversationId: data.conversationId,
});
if (conversation) {
const tags = [...(conversation.tags || []), data.tag];
await updateTagsForConversation(user, data.conversationId, { tags });
} else {
logger.warn('[updateTagsForConversation] Conversation not found', data.conversationId);
}
if (addToConversation && conversationId) {
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $addToSet: { tags: tag } },
{ new: true },
);
}
return await ConversationTag.findOne({ user, tag: data.tag });
return newTag;
} catch (error) {
logger.error('[createConversationTag] Error updating conversation tag', error);
return { message: 'Error updating conversation tag' };
logger.error('[createConversationTag] Error creating conversation tag', error);
throw new Error('Error creating conversation tag');
}
};
const replaceOrRemoveTagInConversations = async (user, oldtag, newtag) => {
/**
* Updates an existing conversation tag.
* @param {string} user - The user ID.
* @param {string} oldTag - The current tag name.
* @param {Object} data - The updated tag data.
* @param {string} [data.tag] - The new tag name.
* @param {string} [data.description] - The updated description.
* @param {number} [data.position] - The new position.
* @returns {Promise<Object>} The updated tag.
*/
const updateConversationTag = async (user, oldTag, data) => {
try {
const conversations = await Conversation.find({ user, tags: { $in: [oldtag] } });
for (const conversation of conversations) {
if (newtag && newtag !== '') {
conversation.tags = conversation.tags.map((tag) => (tag === oldtag ? newtag : tag));
} else {
conversation.tags = conversation.tags.filter((tag) => tag !== oldtag);
const { tag: newTag, description, position } = data;
const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
if (!existingTag) {
return null;
}
if (newTag && newTag !== oldTag) {
const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean();
if (tagAlreadyExists) {
throw new Error('Tag already exists');
}
await conversation.save({ timestamps: { updatedAt: false } });
}
} catch (error) {
logger.error('[replaceOrRemoveTagInConversations] Error updating conversation tags', error);
return { message: 'Error updating conversation tags' };
}
};
const updateTagPosition = async (user, tag, newPosition) => {
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return { message: 'Tag not found' };
await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } });
}
const oldPosition = cTag.position;
if (newPosition === oldPosition) {
return cTag;
const updateData = {};
if (newTag) {
updateData.tag = newTag;
}
if (description !== undefined) {
updateData.description = description;
}
if (position !== undefined) {
await adjustPositions(user, existingTag.position, position);
updateData.position = position;
}
const updateOperations = [];
if (newPosition > oldPosition) {
// Move other tags up
updateOperations.push({
updateMany: {
filter: {
user,
position: { $gt: oldPosition, $lte: newPosition },
tag: { $ne: SAVED_TAG },
},
update: { $inc: { position: -1 } },
},
});
} else {
// Move other tags down
updateOperations.push({
updateMany: {
filter: {
user,
position: { $gte: newPosition, $lt: oldPosition },
tag: { $ne: SAVED_TAG },
},
update: { $inc: { position: 1 } },
},
});
}
// Update the target tag's position
updateOperations.push({
updateOne: {
filter: { _id: cTag._id },
update: { $set: { position: newPosition } },
},
return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, {
new: true,
lean: true,
});
await ConversationTag.bulkWrite(updateOperations);
return await ConversationTag.findById(cTag._id);
} catch (error) {
logger.error('[updateTagPosition] Error updating tag position', error);
return { message: 'Error updating tag position' };
logger.error('[updateConversationTag] Error updating conversation tag', error);
throw new Error('Error updating conversation tag');
}
};
module.exports = {
SAVED_TAG,
ConversationTag,
getConversationTags: async (user) => {
try {
const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean();
cTags.sort((a, b) => (a.tag === SAVED_TAG ? -1 : b.tag === SAVED_TAG ? 1 : 0));
return cTags;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
return { message: 'Error getting share link' };
}
},
/**
* Adjusts positions of tags when a tag's position is changed.
* @param {string} user - The user ID.
* @param {number} oldPosition - The old position of the tag.
* @param {number} newPosition - The new position of the tag.
* @returns {Promise<void>}
*/
const adjustPositions = async (user, oldPosition, newPosition) => {
if (oldPosition === newPosition) {
return;
}
createConversationTag,
updateConversationTag: async (user, tag, data) => {
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return createConversationTag(user, data);
const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } };
const position =
oldPosition < newPosition
? {
$gt: Math.min(oldPosition, newPosition),
$lte: Math.max(oldPosition, newPosition),
}
: {
$gte: Math.min(oldPosition, newPosition),
$lt: Math.max(oldPosition, newPosition),
};
if (cTag.tag !== data.tag || cTag.description !== data.description) {
cTag.tag = data.tag;
cTag.description = data.description === undefined ? cTag.description : data.description;
await cTag.save();
}
if (data.position !== undefined && cTag.position !== data.position) {
await updateTagPosition(user, tag, data.position);
}
// update conversation tags properties
replaceOrRemoveTagInConversations(user, tag, data.tag);
return await ConversationTag.findOne({ user, tag: data.tag });
} catch (error) {
logger.error('[updateConversationTag] Error updating conversation tag', error);
return { message: 'Error updating conversation tag' };
}
},
deleteConversationTag: async (user, tag) => {
try {
const currentTag = await ConversationTag.findOne({ user, tag });
if (!currentTag) {
return;
}
await currentTag.deleteOne({ user, tag });
await replaceOrRemoveTagInConversations(user, tag, null);
return currentTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
return { message: 'Error deleting conversation tag' };
}
},
updateTagsForConversation,
rebuildConversationTags: async (user) => {
try {
const conversations = await Conversation.find({ user }).select('tags');
const tagCountMap = {};
// Count the occurrences of each tag
conversations.forEach((conversation) => {
conversation.tags.forEach((tag) => {
if (tagCountMap[tag]) {
tagCountMap[tag]++;
} else {
tagCountMap[tag] = 1;
}
});
});
const tags = await ConversationTag.find({ user }).sort({ position: -1 });
// Update existing tags and add new tags
for (const [tag, count] of Object.entries(tagCountMap)) {
const existingTag = tags.find((t) => t.tag === tag);
if (existingTag) {
existingTag.count = count;
await existingTag.save();
} else {
const newTag = new ConversationTag({ user, tag, count });
tags.push(newTag);
await newTag.save();
}
}
// Set count to 0 for tags that are not in the grouped tags
for (const tag of tags) {
if (!tagCountMap[tag.tag]) {
tag.count = 0;
await tag.save();
}
}
// Sort tags by position in descending order
tags.sort((a, b) => a.position - b.position);
// Move the tag with name "saved" to the first position
const savedTagIndex = tags.findIndex((tag) => tag.tag === SAVED_TAG);
if (savedTagIndex !== -1) {
const [savedTag] = tags.splice(savedTagIndex, 1);
tags.unshift(savedTag);
}
// Reassign positions starting from 0
tags.forEach((tag, index) => {
tag.position = index;
tag.save();
});
return tags;
} catch (error) {
logger.error('[rearrangeTags] Error rearranging tags', error);
return { message: 'Error rearranging tags' };
}
},
await ConversationTag.updateMany(
{
user,
position,
},
update,
);
};
/**
* Deletes a conversation tag.
* @param {string} user - The user ID.
* @param {string} tag - The tag to delete.
* @returns {Promise<Object>} The deleted tag.
*/
const deleteConversationTag = async (user, tag) => {
try {
const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
if (!deletedTag) {
return null;
}
await Conversation.updateMany({ user, tags: tag }, { $pull: { tags: tag } });
await ConversationTag.updateMany(
{ user, position: { $gt: deletedTag.position } },
{ $inc: { position: -1 } },
);
return deletedTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
throw new Error('Error deleting conversation tag');
}
};
/**
* Updates tags for a specific conversation.
* @param {string} user - The user ID.
* @param {string} conversationId - The conversation ID.
* @param {string[]} tags - The new set of tags for the conversation.
* @returns {Promise<string[]>} The updated list of tags for the conversation.
*/
const updateTagsForConversation = async (user, conversationId, tags) => {
try {
const conversation = await Conversation.findOne({ user, conversationId }).lean();
if (!conversation) {
throw new Error('Conversation not found');
}
const oldTags = new Set(conversation.tags);
const newTags = new Set(tags);
const addedTags = [...newTags].filter((tag) => !oldTags.has(tag));
const removedTags = [...oldTags].filter((tag) => !newTags.has(tag));
const bulkOps = [];
for (const tag of addedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: 1 } },
upsert: true,
},
});
}
for (const tag of removedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: -1 } },
},
});
}
if (bulkOps.length > 0) {
await ConversationTag.bulkWrite(bulkOps);
}
const updatedConversation = (
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $set: { tags: [...newTags] } },
{ new: true },
)
).toObject();
return updatedConversation.tags;
} catch (error) {
logger.error('[updateTagsForConversation] Error updating tags', error);
throw new Error('Error updating tags for conversation');
}
};
module.exports = {
getConversationTags,
createConversationTag,
updateConversationTag,
deleteConversationTag,
updateTagsForConversation,
};

View File

@@ -212,8 +212,8 @@ async function updateMessageText(req, { messageId, text }) {
*
* @async
* @function updateMessage
* @param {Object} message - The message object containing update data.
* @param {Object} req - The request object.
* @param {Object} message - The message object containing update data.
* @param {string} message.messageId - The unique identifier for the message.
* @param {string} [message.text] - The new text content of the message.
* @param {Object[]} [message.files] - The files associated with the message.
@@ -317,7 +317,7 @@ async function getMessages(filter, select) {
* @async
* @function deleteMessages
* @param {Object} filter - The filter criteria to find messages to delete.
* @returns {Promise<Number>} The number of deleted messages.
* @returns {Promise<Object>} The metadata with count of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {

View File

@@ -1,6 +1,15 @@
const { SystemRoles, CacheKeys, roleDefaults } = require('librechat-data-provider');
const {
CacheKeys,
SystemRoles,
roleDefaults,
PermissionTypes,
removeNullishValues,
promptPermissionsSchema,
bookmarkPermissionsSchema,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const Role = require('~/models/schema/roleSchema');
const { logger } = require('~/config');
/**
* Retrieve a role by name and convert the found role document to a plain object.
@@ -61,6 +70,63 @@ const updateRoleByName = async function (roleName, updates) {
}
};
const permissionSchemas = {
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
};
/**
* Updates access permissions for a specific role and multiple permission types.
* @param {SystemRoles} roleName - The role to update.
* @param {Object.<PermissionTypes, Object.<Permissions, boolean>>} permissionsUpdate - Permissions to update and their values.
*/
async function updateAccessPermissions(roleName, permissionsUpdate) {
const updates = {};
for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) {
if (permissionSchemas[permissionType]) {
updates[permissionType] = removeNullishValues(permissions);
}
}
if (Object.keys(updates).length === 0) {
return;
}
try {
const role = await getRoleByName(roleName);
if (!role) {
return;
}
const updatedPermissions = {};
let hasChanges = false;
for (const [permissionType, permissions] of Object.entries(updates)) {
const currentPermissions = role[permissionType] || {};
updatedPermissions[permissionType] = { ...currentPermissions };
for (const [permission, value] of Object.entries(permissions)) {
if (currentPermissions[permission] !== value) {
updatedPermissions[permissionType][permission] = value;
hasChanges = true;
logger.info(
`Updating '${roleName}' role ${permissionType} '${permission}' permission from ${currentPermissions[permission]} to: ${value}`,
);
}
}
}
if (hasChanges) {
await updateRoleByName(roleName, updatedPermissions);
logger.info(`Updated '${roleName}' role permissions`);
} else {
logger.info(`No changes needed for '${roleName}' role permissions`);
}
} catch (error) {
logger.error(`Failed to update ${roleName} role permissions:`, error);
}
}
/**
* Initialize default roles in the system.
* Creates the default roles (ADMIN, USER) if they don't exist in the database.
@@ -83,4 +149,5 @@ module.exports = {
getRoleByName,
initializeRoles,
updateRoleByName,
updateAccessPermissions,
};

197
api/models/Role.spec.js Normal file
View File

@@ -0,0 +1,197 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { SystemRoles, PermissionTypes } = require('librechat-data-provider');
const Role = require('~/models/schema/roleSchema');
const { updateAccessPermissions } = require('~/models/Role');
const getLogStores = require('~/cache/getLogStores');
// Mock the cache
jest.mock('~/cache/getLogStores', () => {
return jest.fn().mockReturnValue({
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
});
});
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Role.deleteMany({});
getLogStores.mockClear();
});
describe('updateAccessPermissions', () => {
it('should update permissions when changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
},
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
});
});
it('should not update permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
});
});
it('should handle non-existent roles', async () => {
await updateAccessPermissions('NON_EXISTENT_ROLE', {
[PermissionTypes.PROMPTS]: {
CREATE: true,
},
});
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
expect(role).toBeNull();
});
it('should update only specified permissions', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
SHARED_GLOBAL: true,
},
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: true,
SHARED_GLOBAL: true,
});
});
it('should handle partial updates', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: {
USE: false,
},
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: false,
});
});
it('should update multiple permission types at once', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
[PermissionTypes.BOOKMARKS]: {
USE: true,
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
[PermissionTypes.BOOKMARKS]: { USE: false },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: true,
});
expect(updatedRole[PermissionTypes.BOOKMARKS]).toEqual({
USE: false,
});
});
it('should handle updates for a single permission type', async () => {
await new Role({
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {
CREATE: true,
USE: true,
SHARED_GLOBAL: false,
},
}).save();
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true },
});
const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean();
expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({
CREATE: true,
USE: false,
SHARED_GLOBAL: true,
});
});
});

View File

@@ -1,6 +1,6 @@
const crypto = require('crypto');
const mongoose = require('mongoose');
const signPayload = require('~/server/services/signPayload');
const { hashToken } = require('~/server/utils/crypto');
const { logger } = require('~/config');
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
@@ -39,8 +39,7 @@ sessionSchema.methods.generateRefreshToken = async function () {
expirationTime: Math.floor((expiresIn - Date.now()) / 1000),
});
const hash = crypto.createHash('sha256');
this.refreshTokenHash = hash.update(refreshToken).digest('hex');
this.refreshTokenHash = await hashToken(refreshToken);
await this.save();

View File

@@ -1,117 +1,252 @@
const crypto = require('crypto');
const { getMessages } = require('./Message');
const { nanoid } = require('nanoid');
const { Constants } = require('librechat-data-provider');
const SharedLink = require('./schema/shareSchema');
const { getMessages } = require('./Message');
const logger = require('~/config/winston');
module.exports = {
SharedLink,
getSharedMessages: async (shareId) => {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
/**
* Anonymizes a conversation ID
* @returns {string} The anonymized conversation ID
*/
function anonymizeConvoId() {
return `convo_${nanoid()}`;
}
if (!share || !share.conversationId || !share.isPublic) {
return null;
}
/**
* Anonymizes an assistant ID
* @returns {string} The anonymized assistant ID
*/
function anonymizeAssistantId() {
return `a_${nanoid()}`;
}
return share;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
throw new Error('Error getting share link');
/**
* Anonymizes a message ID
* @param {string} id - The original message ID
* @returns {string} The anonymized message ID
*/
function anonymizeMessageId(id) {
return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`;
}
/**
* Anonymizes a conversation object
* @param {object} conversation - The conversation object
* @returns {object} The anonymized conversation object
*/
function anonymizeConvo(conversation) {
const newConvo = { ...conversation };
if (newConvo.assistant_id) {
newConvo.assistant_id = anonymizeAssistantId();
}
return newConvo;
}
/**
* Anonymizes messages in a conversation
* @param {TMessage[]} messages - The original messages
* @param {string} newConvoId - The new conversation ID
* @returns {TMessage[]} The anonymized messages
*/
function anonymizeMessages(messages, newConvoId) {
const idMap = new Map();
return messages.map((message) => {
const newMessageId = anonymizeMessageId(message.messageId);
idMap.set(message.messageId, newMessageId);
const anonymizedMessage = Object.assign(message, {
messageId: newMessageId,
parentMessageId:
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
conversationId: newConvoId,
});
if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) {
anonymizedMessage.model = anonymizeAssistantId();
}
},
getSharedLinks: async (user, pageNumber = 1, pageSize = 25, isPublic = true) => {
const query = { user, isPublic };
try {
const totalConvos = (await SharedLink.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const shares = await SharedLink.find(query)
return anonymizedMessage;
});
}
/**
* Retrieves shared messages for a given share ID
* @param {string} shareId - The share ID
* @returns {Promise<object|null>} The shared conversation data or null if not found
*/
async function getSharedMessages(shareId) {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share || !share.conversationId || !share.isPublic) {
return null;
}
const newConvoId = anonymizeConvoId();
return Object.assign(share, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
});
} catch (error) {
logger.error('[getShare] Error getting share link', error);
throw new Error('Error getting share link');
}
}
/**
* Retrieves shared links for a user
* @param {string} user - The user ID
* @param {number} [pageNumber=1] - The page number
* @param {number} [pageSize=25] - The page size
* @param {boolean} [isPublic=true] - Whether to retrieve public links only
* @returns {Promise<object>} The shared links and pagination data
*/
async function getSharedLinks(user, pageNumber = 1, pageSize = 25, isPublic = true) {
const query = { user, isPublic };
try {
const [totalConvos, sharedLinks] = await Promise.all([
SharedLink.countDocuments(query),
SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean();
.lean(),
]);
return { sharedLinks: shares, pages: totalPages, pageNumber, pageSize };
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
},
const totalPages = Math.ceil((totalConvos || 1) / pageSize);
createSharedLink: async (user, { conversationId, ...shareData }) => {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
return share;
}
return {
sharedLinks,
pages: totalPages,
pageNumber,
pageSize,
};
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
throw new Error('Error getting shares');
}
}
const shareId = crypto.randomUUID();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: true,
/**
* Creates a new shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The created shared link
*/
async function createSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(share);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(share.messages, newConvoId),
});
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
}
},
updateSharedLink: async (user, { conversationId, ...shareData }) => {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
const shareId = nanoid();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
const newShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: true,
}).lean();
// update messages to the latest
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: false,
});
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', error);
throw new Error('Error updating shared link');
}
},
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(newShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(newShare.messages, newConvoId),
});
} catch (error) {
logger.error('[createSharedLink] Error creating shared link', error);
throw new Error('Error creating shared link');
}
}
deleteSharedLink: async (user, { shareId }) => {
try {
const share = await SharedLink.findOne({ shareId, user });
if (!share) {
return { message: 'Share not found' };
}
return await SharedLink.findOneAndDelete({ shareId, user });
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
/**
* Updates an existing shared link
* @param {string} user - The user ID
* @param {object} shareData - The share data to update
* @param {string} shareData.conversationId - The conversation ID
* @returns {Promise<object>} The updated shared link
*/
async function updateSharedLink(user, { conversationId, ...shareData }) {
try {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
},
/**
* Deletes all shared links for a specific user.
* @param {string} user - The user ID.
* @returns {Promise<{ message: string, deletedCount?: number }>} A result object indicating success or error message.
*/
deleteAllSharedLinks: async (user) => {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
throw new Error('Error deleting shared links');
}
},
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
new: true,
upsert: false,
}).lean();
const newConvoId = anonymizeConvoId();
const sharedConvo = anonymizeConvo(updatedShare);
return Object.assign(sharedConvo, {
conversationId: newConvoId,
messages: anonymizeMessages(updatedShare.messages, newConvoId),
});
} catch (error) {
logger.error('[updateSharedLink] Error updating shared link', error);
throw new Error('Error updating shared link');
}
}
/**
* Deletes a shared link
* @param {string} user - The user ID
* @param {object} params - The deletion parameters
* @param {string} params.shareId - The share ID to delete
* @returns {Promise<object>} The result of the deletion
*/
async function deleteSharedLink(user, { shareId }) {
try {
const result = await SharedLink.findOneAndDelete({ shareId, user });
return result ? { message: 'Share deleted successfully' } : { message: 'Share not found' };
} catch (error) {
logger.error('[deleteSharedLink] Error deleting shared link', error);
throw new Error('Error deleting shared link');
}
}
/**
* Deletes all shared links for a specific user
* @param {string} user - The user ID
* @returns {Promise<object>} The result of the deletion
*/
async function deleteAllSharedLinks(user) {
try {
const result = await SharedLink.deleteMany({ user });
return {
message: 'All shared links have been deleted successfully',
deletedCount: result.deletedCount,
};
} catch (error) {
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
throw new Error('Error deleting shared links');
}
}
module.exports = {
SharedLink,
getSharedLinks,
createSharedLink,
updateSharedLink,
deleteSharedLink,
getSharedMessages,
deleteAllSharedLinks,
};

117
api/models/Token.js Normal file
View File

@@ -0,0 +1,117 @@
const tokenSchema = require('./schema/tokenSchema');
const mongoose = require('mongoose');
const { logger } = require('~/config');
/**
* Token model.
* @type {mongoose.Model}
*/
const Token = mongoose.model('Token', tokenSchema);
/**
* Creates a new Token instance.
* @param {Object} tokenData - The data for the new Token.
* @param {mongoose.Types.ObjectId} tokenData.userId - The user's ID. It is required.
* @param {String} tokenData.email - The user's email.
* @param {String} tokenData.token - The token. It is required.
* @param {Number} tokenData.expiresIn - The number of seconds until the token expires.
* @returns {Promise<mongoose.Document>} The new Token instance.
* @throws Will throw an error if token creation fails.
*/
async function createToken(tokenData) {
try {
const currentTime = new Date();
const expiresAt = new Date(currentTime.getTime() + tokenData.expiresIn * 1000);
const newTokenData = {
...tokenData,
createdAt: currentTime,
expiresAt,
};
const newToken = new Token(newTokenData);
return await newToken.save();
} catch (error) {
logger.debug('An error occurred while creating token:', error);
throw error;
}
}
/**
* Finds a Token document that matches the provided query.
* @param {Object} query - The query to match against.
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
* @param {String} query.token - The token value.
* @param {String} query.email - The email of the user.
* @returns {Promise<Object|null>} The matched Token document, or null if not found.
* @throws Will throw an error if the find operation fails.
*/
async function findToken(query) {
try {
const conditions = [];
if (query.userId) {
conditions.push({ userId: query.userId });
}
if (query.token) {
conditions.push({ token: query.token });
}
if (query.email) {
conditions.push({ email: query.email });
}
const token = await Token.findOne({
$and: conditions,
}).lean();
return token;
} catch (error) {
logger.debug('An error occurred while finding token:', error);
throw error;
}
}
/**
* Updates a Token document that matches the provided query.
* @param {Object} query - The query to match against.
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
* @param {String} query.token - The token value.
* @param {Object} updateData - The data to update the Token with.
* @returns {Promise<mongoose.Document|null>} The updated Token document, or null if not found.
* @throws Will throw an error if the update operation fails.
*/
async function updateToken(query, updateData) {
try {
return await Token.findOneAndUpdate(query, updateData, { new: true });
} catch (error) {
logger.debug('An error occurred while updating token:', error);
throw error;
}
}
/**
* Deletes all Token documents that match the provided token, user ID, or email.
* @param {Object} query - The query to match against.
* @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user.
* @param {String} query.token - The token value.
* @param {String} query.email - The email of the user.
* @returns {Promise<Object>} The result of the delete operation.
* @throws Will throw an error if the delete operation fails.
*/
async function deleteTokens(query) {
try {
return await Token.deleteMany({
$or: [{ userId: query.userId }, { token: query.token }, { email: query.email }],
});
} catch (error) {
logger.debug('An error occurred while deleting tokens:', error);
throw error;
}
}
module.exports = {
createToken,
findToken,
updateToken,
deleteTokens,
};

View File

@@ -1,12 +1,12 @@
const mongoose = require('mongoose');
const { isEnabled } = require('../server/utils/handleText');
const { isEnabled } = require('~/server/utils/handleText');
const transactionSchema = require('./schema/transaction');
const { getMultiplier } = require('./tx');
const { getMultiplier, getCacheMultiplier } = require('./tx');
const { logger } = require('~/config');
const Balance = require('./Balance');
const cancelRate = 1.15;
// Method to calculate and set the tokenValue for a transaction
/** Method to calculate and set the tokenValue for a transaction */
transactionSchema.methods.calculateTokenValue = function () {
if (!this.valueKey || !this.tokenType) {
this.tokenValue = this.rawAmount;
@@ -21,15 +21,17 @@ transactionSchema.methods.calculateTokenValue = function () {
}
};
// Static method to create a transaction and update the balance
transactionSchema.statics.create = async function (transactionData) {
/**
* Static method to create a transaction and update the balance
* @param {txData} txData - Transaction data.
*/
transactionSchema.statics.create = async function (txData) {
const Transaction = this;
const transaction = new Transaction(transactionData);
transaction.endpointTokenConfig = transactionData.endpointTokenConfig;
const transaction = new Transaction(txData);
transaction.endpointTokenConfig = txData.endpointTokenConfig;
transaction.calculateTokenValue();
// Save the transaction
await transaction.save();
if (!isEnabled(process.env.CHECK_BALANCE)) {
@@ -57,6 +59,109 @@ transactionSchema.statics.create = async function (transactionData) {
};
};
/**
* Static method to create a structured transaction and update the balance
* @param {txData} txData - Transaction data.
*/
transactionSchema.statics.createStructured = async function (txData) {
const Transaction = this;
const transaction = new Transaction({
...txData,
endpointTokenConfig: txData.endpointTokenConfig,
});
transaction.calculateStructuredTokenValue();
await transaction.save();
if (!isEnabled(process.env.CHECK_BALANCE)) {
return;
}
let balance = await Balance.findOne({ user: transaction.user }).lean();
let incrementValue = transaction.tokenValue;
if (balance && balance?.tokenCredits + incrementValue < 0) {
incrementValue = -balance.tokenCredits;
}
balance = await Balance.findOneAndUpdate(
{ user: transaction.user },
{ $inc: { tokenCredits: incrementValue } },
{ upsert: true, new: true },
).lean();
return {
rate: transaction.rate,
user: transaction.user.toString(),
balance: balance.tokenCredits,
[transaction.tokenType]: incrementValue,
};
};
/** Method to calculate token value for structured tokens */
transactionSchema.methods.calculateStructuredTokenValue = function () {
if (!this.tokenType) {
this.tokenValue = this.rawAmount;
return;
}
const { model, endpointTokenConfig } = this;
if (this.tokenType === 'prompt') {
const inputMultiplier = getMultiplier({ tokenType: 'prompt', model, endpointTokenConfig });
const writeMultiplier =
getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? inputMultiplier;
const readMultiplier =
getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? inputMultiplier;
this.rateDetail = {
input: inputMultiplier,
write: writeMultiplier,
read: readMultiplier,
};
const totalPromptTokens =
Math.abs(this.inputTokens || 0) +
Math.abs(this.writeTokens || 0) +
Math.abs(this.readTokens || 0);
if (totalPromptTokens > 0) {
this.rate =
(Math.abs(inputMultiplier * (this.inputTokens || 0)) +
Math.abs(writeMultiplier * (this.writeTokens || 0)) +
Math.abs(readMultiplier * (this.readTokens || 0))) /
totalPromptTokens;
} else {
this.rate = Math.abs(inputMultiplier); // Default to input rate if no tokens
}
this.tokenValue = -(
Math.abs(this.inputTokens || 0) * inputMultiplier +
Math.abs(this.writeTokens || 0) * writeMultiplier +
Math.abs(this.readTokens || 0) * readMultiplier
);
this.rawAmount = -totalPromptTokens;
} else if (this.tokenType === 'completion') {
const multiplier = getMultiplier({ tokenType: this.tokenType, model, endpointTokenConfig });
this.rate = Math.abs(multiplier);
this.tokenValue = -Math.abs(this.rawAmount) * multiplier;
this.rawAmount = -Math.abs(this.rawAmount);
}
if (this.context && this.tokenType === 'completion' && this.context === 'incomplete') {
this.tokenValue = Math.ceil(this.tokenValue * cancelRate);
this.rate *= cancelRate;
if (this.rateDetail) {
this.rateDetail = Object.fromEntries(
Object.entries(this.rateDetail).map(([k, v]) => [k, v * cancelRate]),
);
}
}
};
const Transaction = mongoose.model('Transaction', transactionSchema);
/**

View File

@@ -0,0 +1,348 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const Balance = require('./Balance');
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { getMultiplier, getCacheMultiplier } = require('./tx');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
describe('Regular Token Spending Tests', () => {
test('Balance should decrease when spending tokens with spendTokens', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000; // $10.00
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
};
const tokenUsage = {
promptTokens: 100,
completionTokens: 50,
};
// Act
process.env.CHECK_BALANCE = 'true';
await spendTokens(txData, tokenUsage);
// Assert
console.log('Initial Balance:', initialBalance);
const updatedBalance = await Balance.findOne({ user: userId });
console.log('Updated Balance:', updatedBalance.tokenCredits);
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
const expectedPromptCost = tokenUsage.promptTokens * promptMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const expectedBalance = initialBalance - expectedTotalCost;
expect(updatedBalance.tokenCredits).toBeLessThan(initialBalance);
expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0);
console.log('Expected Total Cost:', expectedTotalCost);
console.log('Actual Balance Decrease:', initialBalance - updatedBalance.tokenCredits);
});
test('spendTokens should handle zero completion tokens', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000; // $10.00
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
};
const tokenUsage = {
promptTokens: 100,
completionTokens: 0,
};
// Act
process.env.CHECK_BALANCE = 'true';
await spendTokens(txData, tokenUsage);
// Assert
const updatedBalance = await Balance.findOne({ user: userId });
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const expectedCost = tokenUsage.promptTokens * promptMultiplier;
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
console.log('Initial Balance:', initialBalance);
console.log('Updated Balance:', updatedBalance.tokenCredits);
console.log('Expected Cost:', expectedCost);
});
test('spendTokens should handle undefined token counts', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000; // $10.00
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
};
const tokenUsage = {};
const result = await spendTokens(txData, tokenUsage);
expect(result).toBeUndefined();
});
test('spendTokens should handle only prompt tokens', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 10000000; // $10.00
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'gpt-3.5-turbo';
const txData = {
user: userId,
conversationId: 'test-conversation-id',
model,
context: 'test',
endpointTokenConfig: null,
};
const tokenUsage = { promptTokens: 100 };
await spendTokens(txData, tokenUsage);
const updatedBalance = await Balance.findOne({ user: userId });
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const expectedCost = 100 * promptMultiplier;
expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0);
});
});
describe('Structured Token Spending Tests', () => {
test('Balance should decrease and rawAmount should be set when spending a large number of structured tokens', async () => {
// Arrange
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55; // $17.61
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'c23a18da-706c-470a-ac28-ec87ed065199',
model,
context: 'message',
endpointTokenConfig: null, // We'll use the default rates
};
const tokenUsage = {
promptTokens: {
input: 11,
write: 140522,
read: 0,
},
completionTokens: 5,
};
// Get the actual multipliers
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
console.log('Multipliers:', {
promptMultiplier,
completionMultiplier,
writeMultiplier,
readMultiplier,
});
// Act
process.env.CHECK_BALANCE = 'true';
const result = await spendStructuredTokens(txData, tokenUsage);
// Assert
console.log('Initial Balance:', initialBalance);
console.log('Updated Balance:', result.completion.balance);
console.log('Transaction Result:', result);
const expectedPromptCost =
tokenUsage.promptTokens.input * promptMultiplier +
tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * readMultiplier;
const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier;
const expectedTotalCost = expectedPromptCost + expectedCompletionCost;
const expectedBalance = initialBalance - expectedTotalCost;
console.log('Expected Cost:', expectedTotalCost);
console.log('Expected Balance:', expectedBalance);
expect(result.completion.balance).toBeLessThan(initialBalance);
// Allow for a small difference (e.g., 100 token credits, which is $0.0001)
const allowedDifference = 100;
expect(Math.abs(result.completion.balance - expectedBalance)).toBeLessThan(allowedDifference);
// Check if the decrease is approximately as expected
const balanceDecrease = initialBalance - result.completion.balance;
expect(balanceDecrease).toBeCloseTo(expectedTotalCost, 0);
// Check token values
const expectedPromptTokenValue = -(
tokenUsage.promptTokens.input * promptMultiplier +
tokenUsage.promptTokens.write * writeMultiplier +
tokenUsage.promptTokens.read * readMultiplier
);
const expectedCompletionTokenValue = -tokenUsage.completionTokens * completionMultiplier;
expect(result.prompt.prompt).toBeCloseTo(expectedPromptTokenValue, 1);
expect(result.completion.completion).toBe(expectedCompletionTokenValue);
console.log('Expected prompt tokenValue:', expectedPromptTokenValue);
console.log('Actual prompt tokenValue:', result.prompt.prompt);
console.log('Expected completion tokenValue:', expectedCompletionTokenValue);
console.log('Actual completion tokenValue:', result.completion.completion);
});
test('should handle zero completion tokens in structured spending', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'test-convo',
model,
context: 'message',
};
const tokenUsage = {
promptTokens: {
input: 10,
write: 100,
read: 5,
},
completionTokens: 0,
};
process.env.CHECK_BALANCE = 'true';
const result = await spendStructuredTokens(txData, tokenUsage);
expect(result.prompt).toBeDefined();
expect(result.completion).toBeUndefined();
expect(result.prompt.prompt).toBeLessThan(0);
});
test('should handle only prompt tokens in structured spending', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'test-convo',
model,
context: 'message',
};
const tokenUsage = {
promptTokens: {
input: 10,
write: 100,
read: 5,
},
};
process.env.CHECK_BALANCE = 'true';
const result = await spendStructuredTokens(txData, tokenUsage);
expect(result.prompt).toBeDefined();
expect(result.completion).toBeUndefined();
expect(result.prompt.prompt).toBeLessThan(0);
});
test('should handle undefined token counts in structured spending', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'test-convo',
model,
context: 'message',
};
const tokenUsage = {};
process.env.CHECK_BALANCE = 'true';
const result = await spendStructuredTokens(txData, tokenUsage);
expect(result).toEqual({
prompt: undefined,
completion: undefined,
});
});
test('should handle incomplete context for completion tokens', async () => {
const userId = new mongoose.Types.ObjectId();
const initialBalance = 17613154.55;
await Balance.create({ user: userId, tokenCredits: initialBalance });
const model = 'claude-3-5-sonnet';
const txData = {
user: userId,
conversationId: 'test-convo',
model,
context: 'incomplete',
};
const tokenUsage = {
promptTokens: {
input: 10,
write: 100,
read: 5,
},
completionTokens: 50,
};
process.env.CHECK_BALANCE = 'true';
const result = await spendStructuredTokens(txData, tokenUsage);
expect(result.completion.completion).toBeCloseTo(-50 * 15 * 1.15, 0); // Assuming multiplier is 15 and cancelRate is 1.15
});
});

View File

@@ -1,11 +1,3 @@
const {
getMessages,
saveMessage,
recordMessage,
updateMessage,
deleteMessagesSince,
deleteMessages,
} = require('./Message');
const {
comparePassword,
deleteUserById,
@@ -16,8 +8,6 @@ const {
countUsers,
findUser,
} = require('./userMethods');
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
const {
findFileById,
createFile,
@@ -27,26 +17,40 @@ const {
getFiles,
updateFileUsage,
} = require('./File');
const Key = require('./Key');
const User = require('./User');
const {
getMessages,
saveMessage,
recordMessage,
updateMessage,
deleteMessagesSince,
deleteMessages,
} = require('./Message');
const { getConvoTitle, getConvo, saveConvo, deleteConvos } = require('./Conversation');
const { getPreset, getPresets, savePreset, deletePresets } = require('./Preset');
const { createToken, findToken, updateToken, deleteTokens } = require('./Token');
const Session = require('./Session');
const Balance = require('./Balance');
const User = require('./User');
const Key = require('./Key');
module.exports = {
User,
Key,
Session,
Balance,
comparePassword,
deleteUserById,
generateToken,
getUserById,
countUsers,
createUser,
updateUser,
createUser,
countUsers,
findUser,
findFileById,
createFile,
updateFile,
deleteFile,
deleteFiles,
getFiles,
updateFileUsage,
getMessages,
saveMessage,
recordMessage,
@@ -64,11 +68,13 @@ module.exports = {
savePreset,
deletePresets,
findFileById,
createFile,
updateFile,
deleteFile,
deleteFiles,
getFiles,
updateFileUsage,
createToken,
findToken,
updateToken,
deleteTokens,
User,
Key,
Session,
Balance,
};

70
api/models/inviteUser.js Normal file
View File

@@ -0,0 +1,70 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const mongoose = require('mongoose');
const { createToken, findToken } = require('./Token');
const logger = require('~/config/winston');
/**
* @module inviteUser
* @description This module provides functions to create and get user invites
*/
/**
* @function createInvite
* @description This function creates a new user invite
* @param {string} email - The email of the user to invite
* @returns {Promise<Object>} A promise that resolves to the saved invite document
* @throws {Error} If there is an error creating the invite
*/
const createInvite = async (email) => {
try {
let token = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(token, 10);
const encodedToken = encodeURIComponent(token);
const fakeUserId = new mongoose.Types.ObjectId();
await createToken({
userId: fakeUserId,
email,
token: hash,
createdAt: Date.now(),
expiresIn: 604800,
});
return encodedToken;
} catch (error) {
logger.error('[createInvite] Error creating invite', error);
return { message: 'Error creating invite' };
}
};
/**
* @function getInvite
* @description This function retrieves a user invite
* @param {string} encodedToken - The token of the invite to retrieve
* @param {string} email - The email of the user to validate
* @returns {Promise<Object>} A promise that resolves to the retrieved invite document
* @throws {Error} If there is an error retrieving the invite, if the invite does not exist, or if the email does not match
*/
const getInvite = async (encodedToken, email) => {
try {
const token = decodeURIComponent(encodedToken);
const hash = bcrypt.hashSync(token, 10);
const invite = await findToken({ token: hash, email });
if (!invite) {
throw new Error('Invite not found or email does not match');
}
return invite;
} catch (error) {
logger.error('[getInvite] Error getting invite', error);
return { error: true, message: error.message };
}
};
module.exports = {
createInvite,
getInvite,
};

View File

@@ -61,6 +61,7 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
}
convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1 }, { unique: true });
const Conversation = mongoose.models.Conversation || mongoose.model('Conversation', convoSchema);

View File

@@ -74,6 +74,10 @@ const conversationPreset = {
resendImages: {
type: Boolean,
},
/* Anthropic only */
promptCache: {
type: Boolean,
},
// files
resendFiles: {
type: Boolean,

View File

@@ -131,6 +131,7 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
messageSchema.index({ createdAt: 1 });
messageSchema.index({ messageId: 1, user: 1 }, { unique: true });
/** @type {mongoose.Model<TMessage>} */
const Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
module.exports = Message;

View File

@@ -8,6 +8,12 @@ const roleSchema = new mongoose.Schema({
unique: true,
index: true,
},
[PermissionTypes.BOOKMARKS]: {
[Permissions.USE]: {
type: Boolean,
default: true,
},
},
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: {
type: Boolean,

View File

@@ -18,8 +18,13 @@ const tokenSchema = new Schema({
type: Date,
required: true,
default: Date.now,
expires: 900,
},
expiresAt: {
type: Date,
required: true,
},
});
module.exports = mongoose.model('Token', tokenSchema);
tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
module.exports = tokenSchema;

View File

@@ -30,6 +30,9 @@ const transactionSchema = mongoose.Schema(
rate: Number,
rawAmount: Number,
tokenValue: Number,
inputTokens: { type: Number },
writeTokens: { type: Number },
readTokens: { type: Number },
},
{
timestamps: true,

View File

@@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const { SystemRoles } = require('librechat-data-provider');
/**
* @typedef {Object} MongoSession
@@ -78,7 +79,7 @@ const userSchema = mongoose.Schema(
},
role: {
type: String,
default: 'USER',
default: SystemRoles.USER,
},
googleId: {
type: String,

View File

@@ -11,7 +11,7 @@ const { logger } = require('~/config');
* @param {String} txData.conversationId - The ID of the conversation.
* @param {String} txData.model - The model name.
* @param {String} txData.context - The context in which the transaction is made.
* @param {String} [txData.endpointTokenConfig] - The current endpoint token config.
* @param {EndpointTokenConfig} [txData.endpointTokenConfig] - The current endpoint token config.
* @param {String} [txData.valueKey] - The value key (optional).
* @param {Object} tokenUsage - The number of tokens used.
* @param {Number} tokenUsage.promptTokens - The number of prompt tokens used.
@@ -32,38 +32,109 @@ const spendTokens = async (txData, tokenUsage) => {
);
let prompt, completion;
try {
if (promptTokens >= 0) {
if (promptTokens !== undefined) {
prompt = await Transaction.create({
...txData,
tokenType: 'prompt',
rawAmount: -promptTokens,
rawAmount: -Math.max(promptTokens, 0),
});
}
if (!completionTokens && isNaN(completionTokens)) {
logger.debug('[spendTokens] !completionTokens', { prompt, completion });
return;
if (completionTokens !== undefined) {
completion = await Transaction.create({
...txData,
tokenType: 'completion',
rawAmount: -Math.max(completionTokens, 0),
});
}
completion = await Transaction.create({
...txData,
tokenType: 'completion',
rawAmount: -completionTokens,
});
prompt &&
completion &&
if (prompt || completion) {
logger.debug('[spendTokens] Transaction data record against balance:', {
user: txData.user,
prompt: prompt.prompt,
promptRate: prompt.rate,
completion: completion.completion,
completionRate: completion.rate,
balance: completion.balance,
prompt: prompt?.prompt,
promptRate: prompt?.rate,
completion: completion?.completion,
completionRate: completion?.rate,
balance: completion?.balance ?? prompt?.balance,
});
} else {
logger.debug('[spendTokens] No transactions incurred against balance');
}
} catch (err) {
logger.error('[spendTokens]', err);
}
};
module.exports = spendTokens;
/**
* Creates transactions to record the spending of structured tokens.
*
* @function
* @async
* @param {Object} txData - Transaction data.
* @param {mongoose.Schema.Types.ObjectId} txData.user - The user ID.
* @param {String} txData.conversationId - The ID of the conversation.
* @param {String} txData.model - The model name.
* @param {String} txData.context - The context in which the transaction is made.
* @param {EndpointTokenConfig} [txData.endpointTokenConfig] - The current endpoint token config.
* @param {String} [txData.valueKey] - The value key (optional).
* @param {Object} tokenUsage - The number of tokens used.
* @param {Object} tokenUsage.promptTokens - The number of prompt tokens used.
* @param {Number} tokenUsage.promptTokens.input - The number of input tokens.
* @param {Number} tokenUsage.promptTokens.write - The number of write tokens.
* @param {Number} tokenUsage.promptTokens.read - The number of read tokens.
* @param {Number} tokenUsage.completionTokens - The number of completion tokens used.
* @returns {Promise<void>} - Returns nothing.
* @throws {Error} - Throws an error if there's an issue creating the transactions.
*/
const spendStructuredTokens = async (txData, tokenUsage) => {
const { promptTokens, completionTokens } = tokenUsage;
logger.debug(
`[spendStructuredTokens] conversationId: ${txData.conversationId}${
txData?.context ? ` | Context: ${txData?.context}` : ''
} | Token usage: `,
{
promptTokens,
completionTokens,
},
);
let prompt, completion;
try {
if (promptTokens) {
const { input = 0, write = 0, read = 0 } = promptTokens;
prompt = await Transaction.createStructured({
...txData,
tokenType: 'prompt',
inputTokens: -input,
writeTokens: -write,
readTokens: -read,
});
}
if (completionTokens) {
completion = await Transaction.create({
...txData,
tokenType: 'completion',
rawAmount: -completionTokens,
});
}
if (prompt || completion) {
logger.debug('[spendStructuredTokens] Transaction data record against balance:', {
user: txData.user,
prompt: prompt?.prompt,
promptRate: prompt?.rate,
completion: completion?.completion,
completionRate: completion?.rate,
balance: completion?.balance ?? prompt?.balance,
});
} else {
logger.debug('[spendStructuredTokens] No transactions incurred against balance');
}
} catch (err) {
logger.error('[spendStructuredTokens]', err);
}
return { prompt, completion };
};
module.exports = { spendTokens, spendStructuredTokens };

View File

@@ -0,0 +1,197 @@
const mongoose = require('mongoose');
jest.mock('./Transaction', () => ({
Transaction: {
create: jest.fn(),
createStructured: jest.fn(),
},
}));
jest.mock('./Balance', () => ({
findOne: jest.fn(),
findOneAndUpdate: jest.fn(),
}));
jest.mock('~/config', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
},
}));
// Import after mocking
const { spendTokens, spendStructuredTokens } = require('./spendTokens');
const { Transaction } = require('./Transaction');
const Balance = require('./Balance');
describe('spendTokens', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.CHECK_BALANCE = 'true';
});
it('should create transactions for both prompt and completion tokens', async () => {
const txData = {
user: new mongoose.Types.ObjectId(),
conversationId: 'test-convo',
model: 'gpt-3.5-turbo',
context: 'test',
};
const tokenUsage = {
promptTokens: 100,
completionTokens: 50,
};
Transaction.create.mockResolvedValueOnce({ tokenType: 'prompt', rawAmount: -100 });
Transaction.create.mockResolvedValueOnce({ tokenType: 'completion', rawAmount: -50 });
Balance.findOne.mockResolvedValue({ tokenCredits: 10000 });
Balance.findOneAndUpdate.mockResolvedValue({ tokenCredits: 9850 });
await spendTokens(txData, tokenUsage);
expect(Transaction.create).toHaveBeenCalledTimes(2);
expect(Transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'prompt',
rawAmount: -100,
}),
);
expect(Transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'completion',
rawAmount: -50,
}),
);
});
it('should handle zero completion tokens', async () => {
const txData = {
user: new mongoose.Types.ObjectId(),
conversationId: 'test-convo',
model: 'gpt-3.5-turbo',
context: 'test',
};
const tokenUsage = {
promptTokens: 100,
completionTokens: 0,
};
Transaction.create.mockResolvedValueOnce({ tokenType: 'prompt', rawAmount: -100 });
Transaction.create.mockResolvedValueOnce({ tokenType: 'completion', rawAmount: -0 });
Balance.findOne.mockResolvedValue({ tokenCredits: 10000 });
Balance.findOneAndUpdate.mockResolvedValue({ tokenCredits: 9850 });
await spendTokens(txData, tokenUsage);
expect(Transaction.create).toHaveBeenCalledTimes(2);
expect(Transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'prompt',
rawAmount: -100,
}),
);
expect(Transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'completion',
rawAmount: -0, // Changed from 0 to -0
}),
);
});
it('should handle undefined token counts', async () => {
const txData = {
user: new mongoose.Types.ObjectId(),
conversationId: 'test-convo',
model: 'gpt-3.5-turbo',
context: 'test',
};
const tokenUsage = {};
await spendTokens(txData, tokenUsage);
expect(Transaction.create).not.toHaveBeenCalled();
});
it('should not update balance when CHECK_BALANCE is false', async () => {
process.env.CHECK_BALANCE = 'false';
const txData = {
user: new mongoose.Types.ObjectId(),
conversationId: 'test-convo',
model: 'gpt-3.5-turbo',
context: 'test',
};
const tokenUsage = {
promptTokens: 100,
completionTokens: 50,
};
Transaction.create.mockResolvedValueOnce({ tokenType: 'prompt', rawAmount: -100 });
Transaction.create.mockResolvedValueOnce({ tokenType: 'completion', rawAmount: -50 });
await spendTokens(txData, tokenUsage);
expect(Transaction.create).toHaveBeenCalledTimes(2);
expect(Balance.findOne).not.toHaveBeenCalled();
expect(Balance.findOneAndUpdate).not.toHaveBeenCalled();
});
it('should create structured transactions for both prompt and completion tokens', async () => {
const txData = {
user: new mongoose.Types.ObjectId(),
conversationId: 'test-convo',
model: 'claude-3-5-sonnet',
context: 'test',
};
const tokenUsage = {
promptTokens: {
input: 10,
write: 100,
read: 5,
},
completionTokens: 50,
};
Transaction.createStructured.mockResolvedValueOnce({
rate: 3.75,
user: txData.user.toString(),
balance: 9570,
prompt: -430,
});
Transaction.create.mockResolvedValueOnce({
rate: 15,
user: txData.user.toString(),
balance: 8820,
completion: -750,
});
const result = await spendStructuredTokens(txData, tokenUsage);
expect(Transaction.createStructured).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'prompt',
inputTokens: -10,
writeTokens: -100,
readTokens: -5,
}),
);
expect(Transaction.create).toHaveBeenCalledWith(
expect.objectContaining({
tokenType: 'completion',
rawAmount: -50,
}),
);
expect(result).toEqual({
prompt: expect.objectContaining({
rate: 3.75,
user: txData.user.toString(),
balance: 9570,
prompt: -430,
}),
completion: expect.objectContaining({
rate: 15,
user: txData.user.toString(),
balance: 8820,
completion: -750,
}),
});
});
});

View File

@@ -1,37 +1,86 @@
const { matchModelName } = require('../utils');
const defaultRate = 6;
/** AWS Bedrock pricing */
const bedrockValues = {
'anthropic.claude-3-haiku-20240307-v1:0': { prompt: 0.25, completion: 1.25 },
'anthropic.claude-3-sonnet-20240229-v1:0': { prompt: 3.0, completion: 15.0 },
'anthropic.claude-3-opus-20240229-v1:0': { prompt: 15.0, completion: 75.0 },
'anthropic.claude-3-5-sonnet-20240620-v1:0': { prompt: 3.0, completion: 15.0 },
'anthropic.claude-v2:1': { prompt: 8.0, completion: 24.0 },
'anthropic.claude-instant-v1': { prompt: 0.8, completion: 2.4 },
'meta.llama2-13b-chat-v1': { prompt: 0.75, completion: 1.0 },
'meta.llama2-70b-chat-v1': { prompt: 1.95, completion: 2.56 },
'meta.llama3-8b-instruct-v1:0': { prompt: 0.3, completion: 0.6 },
'meta.llama3-70b-instruct-v1:0': { prompt: 2.65, completion: 3.5 },
'meta.llama3-1-8b-instruct-v1:0': { prompt: 0.3, completion: 0.6 },
'meta.llama3-1-70b-instruct-v1:0': { prompt: 2.65, completion: 3.5 },
'meta.llama3-1-405b-instruct-v1:0': { prompt: 5.32, completion: 16.0 },
'mistral.mistral-7b-instruct-v0:2': { prompt: 0.15, completion: 0.2 },
'mistral.mistral-small-2402-v1:0': { prompt: 0.15, completion: 0.2 },
'mistral.mixtral-8x7b-instruct-v0:1': { prompt: 0.45, completion: 0.7 },
'mistral.mistral-large-2402-v1:0': { prompt: 4.0, completion: 12.0 },
'mistral.mistral-large-2407-v1:0': { prompt: 3.0, completion: 9.0 },
'cohere.command-text-v14': { prompt: 1.5, completion: 2.0 },
'cohere.command-light-text-v14': { prompt: 0.3, completion: 0.6 },
'cohere.command-r-v1:0': { prompt: 0.5, completion: 1.5 },
'cohere.command-r-plus-v1:0': { prompt: 3.0, completion: 15.0 },
'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 },
'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 },
'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 },
'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 },
};
for (const [key, value] of Object.entries(bedrockValues)) {
bedrockValues[`bedrock/${key}`] = value;
}
/**
* Mapping of model token sizes to their respective multipliers for prompt and completion.
* The rates are 1 USD per 1M tokens.
* @type {Object.<string, {prompt: number, completion: number}>}
*/
const tokenValues = {
'8k': { prompt: 30, completion: 60 },
'32k': { prompt: 60, completion: 120 },
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-4o': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-2': { prompt: 8, completion: 24 },
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
/* cohere doesn't have rates for the older command models,
const tokenValues = Object.assign(
{
'8k': { prompt: 30, completion: 60 },
'32k': { prompt: 60, completion: 120 },
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'gpt-4o-2024-08-06': { prompt: 2.5, completion: 10 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-4o': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
'claude-3.5-sonnet': { prompt: 3, completion: 15 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-2': { prompt: 8, completion: 24 },
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
/* cohere doesn't have rates for the older command models,
so this was from https://artificialanalysis.ai/models/command-light/providers */
command: { prompt: 0.38, completion: 0.38 },
// 'gemini-1.5': { prompt: 7, completion: 21 }, // May 2nd, 2024 pricing
// 'gemini': { prompt: 0.5, completion: 1.5 }, // May 2nd, 2024 pricing
'gemini-1.5': { prompt: 0, completion: 0 }, // currently free
gemini: { prompt: 0, completion: 0 }, // currently free
command: { prompt: 0.38, completion: 0.38 },
'gemini-1.5': { prompt: 7, completion: 21 }, // May 2nd, 2024 pricing
gemini: { prompt: 0.5, completion: 1.5 }, // May 2nd, 2024 pricing
},
bedrockValues,
);
/**
* Mapping of model token sizes to their respective multipliers for cached input, read and write.
* See Anthropic's documentation on this: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#pricing
* The rates are 1 USD per 1M tokens.
* @type {Object.<string, {write: number, read: number }>}
*/
const cacheTokenValues = {
'claude-3.5-sonnet': { write: 3.75, read: 0.3 },
'claude-3-5-sonnet': { write: 3.75, read: 0.3 },
'claude-3-haiku': { write: 0.3, read: 0.03 },
};
/**
@@ -55,6 +104,8 @@ const getValueKey = (model, endpoint) => {
return 'gpt-3.5-turbo-1106';
} else if (modelName.includes('gpt-3.5')) {
return '4k';
} else if (modelName.includes('gpt-4o-2024-08-06')) {
return 'gpt-4o-2024-08-06';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {
@@ -84,7 +135,7 @@ const getValueKey = (model, endpoint) => {
*
* @param {Object} params - The parameters for the function.
* @param {string} [params.valueKey] - The key corresponding to the model name.
* @param {string} [params.tokenType] - The type of token (e.g., 'prompt' or 'completion').
* @param {'prompt' | 'completion'} [params.tokenType] - The type of token (e.g., 'prompt' or 'completion').
* @param {string} [params.model] - The model name to derive the value key from if not provided.
* @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
* @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
@@ -109,7 +160,41 @@ const getMultiplier = ({ valueKey, tokenType, model, endpoint, endpointTokenConf
}
// If we got this far, and values[tokenType] is undefined somehow, return a rough average of default multipliers
return tokenValues[valueKey][tokenType] ?? defaultRate;
return tokenValues[valueKey]?.[tokenType] ?? defaultRate;
};
module.exports = { tokenValues, getValueKey, getMultiplier, defaultRate };
/**
* Retrieves the cache multiplier for a given value key and token type. If no value key is provided,
* it attempts to derive it from the model name.
*
* @param {Object} params - The parameters for the function.
* @param {string} [params.valueKey] - The key corresponding to the model name.
* @param {'write' | 'read'} [params.cacheType] - The type of token (e.g., 'write' or 'read').
* @param {string} [params.model] - The model name to derive the value key from if not provided.
* @param {string} [params.endpoint] - The endpoint name to derive the value key from if not provided.
* @param {EndpointTokenConfig} [params.endpointTokenConfig] - The token configuration for the endpoint.
* @returns {number | null} The multiplier for the given parameters, or `null` if not found.
*/
const getCacheMultiplier = ({ valueKey, cacheType, model, endpoint, endpointTokenConfig }) => {
if (endpointTokenConfig) {
return endpointTokenConfig?.[model]?.[cacheType] ?? null;
}
if (valueKey && cacheType) {
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
}
if (!cacheType || !model) {
return null;
}
valueKey = getValueKey(model, endpoint);
if (!valueKey) {
return null;
}
// If we got this far, and values[cacheType] is undefined somehow, return a rough average of default multipliers
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
};
module.exports = { tokenValues, getValueKey, getMultiplier, getCacheMultiplier, defaultRate };

View File

@@ -1,4 +1,10 @@
const { getValueKey, getMultiplier, defaultRate, tokenValues } = require('./tx');
const {
defaultRate,
tokenValues,
getValueKey,
getMultiplier,
getCacheMultiplier,
} = require('./tx');
describe('getValueKey', () => {
it('should return "16k" for model name containing "gpt-3.5-turbo-16k"', () => {
@@ -53,6 +59,21 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-4o-mini-2024-07-18')).toBe('gpt-4o-mini');
expect(getValueKey('openai/gpt-4o-mini')).toBe('gpt-4o-mini');
expect(getValueKey('gpt-4o-mini-0718')).toBe('gpt-4o-mini');
expect(getValueKey('gpt-4o-2024-08-06-0718')).not.toBe('gpt-4o');
});
it('should return "gpt-4o-2024-08-06" for model type of "gpt-4o-2024-08-06"', () => {
expect(getValueKey('gpt-4o-2024-08-06-2024-07-18')).toBe('gpt-4o-2024-08-06');
expect(getValueKey('openai/gpt-4o-2024-08-06')).toBe('gpt-4o-2024-08-06');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o-2024-08-06');
expect(getValueKey('gpt-4o-2024-08-06-0718')).not.toBe('gpt-4o');
});
it('should return "gpt-4o" for model type of "chatgpt-4o"', () => {
expect(getValueKey('chatgpt-4o-latest')).toBe('gpt-4o');
expect(getValueKey('openai/chatgpt-4o-latest')).toBe('gpt-4o');
expect(getValueKey('chatgpt-4o-latest-0916')).toBe('gpt-4o');
expect(getValueKey('chatgpt-4o-latest-0718')).toBe('gpt-4o');
});
it('should return "claude-3-5-sonnet" for model type of "claude-3-5-sonnet-"', () => {
@@ -61,6 +82,13 @@ describe('getValueKey', () => {
expect(getValueKey('claude-3-5-sonnet-turbo')).toBe('claude-3-5-sonnet');
expect(getValueKey('claude-3-5-sonnet-0125')).toBe('claude-3-5-sonnet');
});
it('should return "claude-3.5-sonnet" for model type of "claude-3.5-sonnet-"', () => {
expect(getValueKey('claude-3.5-sonnet-20240620')).toBe('claude-3.5-sonnet');
expect(getValueKey('anthropic/claude-3.5-sonnet')).toBe('claude-3.5-sonnet');
expect(getValueKey('claude-3.5-sonnet-turbo')).toBe('claude-3.5-sonnet');
expect(getValueKey('claude-3.5-sonnet-0125')).toBe('claude-3.5-sonnet');
});
});
describe('getMultiplier', () => {
@@ -128,6 +156,17 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for chatgpt-4o-latest', () => {
const valueKey = getValueKey('chatgpt-4o-latest');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-4o'].completion,
);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe(
tokenValues['gpt-4o-mini'].completion,
);
});
it('should derive the valueKey from the model if not provided for new models', () => {
expect(
getMultiplier({ tokenType: 'prompt', model: 'gpt-3.5-turbo-1106-some-other-info' }),
@@ -152,3 +191,141 @@ describe('getMultiplier', () => {
);
});
});
describe('AWS Bedrock Model Tests', () => {
const awsModels = [
'anthropic.claude-3-haiku-20240307-v1:0',
'anthropic.claude-3-sonnet-20240229-v1:0',
'anthropic.claude-3-opus-20240229-v1:0',
'anthropic.claude-3-5-sonnet-20240620-v1:0',
'anthropic.claude-v2:1',
'anthropic.claude-instant-v1',
'meta.llama2-13b-chat-v1',
'meta.llama2-70b-chat-v1',
'meta.llama3-8b-instruct-v1:0',
'meta.llama3-70b-instruct-v1:0',
'meta.llama3-1-8b-instruct-v1:0',
'meta.llama3-1-70b-instruct-v1:0',
'meta.llama3-1-405b-instruct-v1:0',
'mistral.mistral-7b-instruct-v0:2',
'mistral.mistral-small-2402-v1:0',
'mistral.mixtral-8x7b-instruct-v0:1',
'mistral.mistral-large-2402-v1:0',
'mistral.mistral-large-2407-v1:0',
'cohere.command-text-v14',
'cohere.command-light-text-v14',
'cohere.command-r-v1:0',
'cohere.command-r-plus-v1:0',
'ai21.j2-mid-v1',
'ai21.j2-ultra-v1',
'amazon.titan-text-lite-v1',
'amazon.titan-text-express-v1',
];
it('should return the correct prompt multipliers for all models', () => {
const results = awsModels.map((model) => {
const multiplier = getMultiplier({ valueKey: model, tokenType: 'prompt' });
return multiplier === tokenValues[model].prompt;
});
expect(results.every(Boolean)).toBe(true);
});
it('should return the correct completion multipliers for all models', () => {
const results = awsModels.map((model) => {
const multiplier = getMultiplier({ valueKey: model, tokenType: 'completion' });
return multiplier === tokenValues[model].completion;
});
expect(results.every(Boolean)).toBe(true);
});
it('should return the correct prompt multipliers for all models with Bedrock prefix', () => {
const results = awsModels.map((model) => {
const modelName = `bedrock/${model}`;
const multiplier = getMultiplier({ valueKey: modelName, tokenType: 'prompt' });
return multiplier === tokenValues[model].prompt;
});
expect(results.every(Boolean)).toBe(true);
});
it('should return the correct completion multipliers for all models with Bedrock prefix', () => {
const results = awsModels.map((model) => {
const modelName = `bedrock/${model}`;
const multiplier = getMultiplier({ valueKey: modelName, tokenType: 'completion' });
return multiplier === tokenValues[model].completion;
});
expect(results.every(Boolean)).toBe(true);
});
});
describe('getCacheMultiplier', () => {
it('should return the correct cache multiplier for a given valueKey and cacheType', () => {
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe(3.75);
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'read' })).toBe(0.3);
expect(getCacheMultiplier({ valueKey: 'claude-3-haiku', cacheType: 'write' })).toBe(0.3);
expect(getCacheMultiplier({ valueKey: 'claude-3-haiku', cacheType: 'read' })).toBe(0.03);
});
it('should return null if cacheType is provided but not found in cacheTokenValues', () => {
expect(
getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'unknownType' }),
).toBeNull();
});
it('should derive the valueKey from the model if not provided', () => {
expect(getCacheMultiplier({ cacheType: 'write', model: 'claude-3-5-sonnet-20240620' })).toBe(
3.75,
);
expect(getCacheMultiplier({ cacheType: 'read', model: 'claude-3-haiku-20240307' })).toBe(0.03);
});
it('should return null if only model or cacheType is missing', () => {
expect(getCacheMultiplier({ cacheType: 'write' })).toBeNull();
expect(getCacheMultiplier({ model: 'claude-3-5-sonnet' })).toBeNull();
});
it('should return null if derived valueKey does not match any known patterns', () => {
expect(getCacheMultiplier({ cacheType: 'write', model: 'gpt-4-some-other-info' })).toBeNull();
});
it('should handle endpointTokenConfig if provided', () => {
const endpointTokenConfig = {
'custom-model': {
write: 5,
read: 1,
},
};
expect(
getCacheMultiplier({ model: 'custom-model', cacheType: 'write', endpointTokenConfig }),
).toBe(5);
expect(
getCacheMultiplier({ model: 'custom-model', cacheType: 'read', endpointTokenConfig }),
).toBe(1);
});
it('should return null if model is not found in endpointTokenConfig', () => {
const endpointTokenConfig = {
'custom-model': {
write: 5,
read: 1,
},
};
expect(
getCacheMultiplier({ model: 'unknown-model', cacheType: 'write', endpointTokenConfig }),
).toBeNull();
});
it('should handle models with "bedrock/" prefix', () => {
expect(
getCacheMultiplier({
model: 'bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0',
cacheType: 'write',
}),
).toBe(3.75);
expect(
getCacheMultiplier({
model: 'bedrock/anthropic.claude-3-haiku-20240307-v1:0',
cacheType: 'read',
}),
).toBe(0.03);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "0.7.4-rc1",
"version": "v0.7.5-rc1",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -12,6 +12,7 @@
"list-balances": "node ./list-balances.js",
"user-stats": "node ./user-stats.js",
"create-user": "node ./create-user.js",
"invite-user": "node ./invite-user.js",
"ban-user": "node ./ban-user.js",
"delete-user": "node ./delete-user.js"
},
@@ -45,9 +46,11 @@
"bcryptjs": "^2.4.3",
"cheerio": "^1.0.0-rc.12",
"cohere-ai": "^7.9.1",
"compression": "^1.7.4",
"connect-redis": "^7.1.0",
"cookie": "^0.5.0",
"cors": "^2.8.5",
"dedent": "^1.5.3",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-mongo-sanitize": "^2.2.0",
@@ -72,6 +75,7 @@
"module-alias": "^2.2.3",
"mongoose": "^7.1.1",
"multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7",
"nodejs-gpt": "^1.37.4",
"nodemailer": "^6.9.4",
"ollama": "^0.5.0",
@@ -98,7 +102,8 @@
"zod": "^3.22.4"
},
"devDependencies": {
"jest": "^29.5.0",
"jest": "^29.7.0",
"mongodb-memory-server": "^10.0.0",
"nodemon": "^3.0.1",
"supertest": "^6.3.3"
}

View File

@@ -1,4 +1,3 @@
const crypto = require('crypto');
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const {
@@ -7,6 +6,7 @@ const {
setAuthTokens,
requestPasswordReset,
} = require('~/server/services/AuthService');
const { hashToken } = require('~/server/utils/crypto');
const { Session, getUserById } = require('~/models');
const { logger } = require('~/config');
@@ -74,8 +74,7 @@ const refreshController = async (req, res) => {
}
// Hash the refresh token
const hash = crypto.createHash('sha256');
const hashedToken = hash.update(refreshToken).digest('hex');
const hashedToken = await hashToken(refreshToken);
// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });

View File

@@ -1,4 +1,4 @@
const Balance = require('../../models/Balance');
const Balance = require('~/models/Balance');
async function balanceController(req, res) {
const { tokenCredits: balance = '' } =

View File

@@ -54,6 +54,7 @@ const chatV1 = async (req, res) => {
promptPrefix,
assistant_id,
instructions,
endpointOption,
thread_id: _thread_id,
messageId: _messageId,
conversationId: convoId,
@@ -283,7 +284,7 @@ const chatV1 = async (req, res) => {
const { openai: _openai, client } = await getOpenAIClient({
req,
res,
endpointOption: req.body.endpointOption,
endpointOption,
initAppClient: true,
});
@@ -312,6 +313,10 @@ const chatV1 = async (req, res) => {
body.additional_instructions = promptPrefix;
}
if (typeof endpointOption.artifactsPrompt === 'string' && endpointOption.artifactsPrompt) {
body.additional_instructions = `${body.additional_instructions ?? ''}\n${endpointOption.artifactsPrompt}`.trim();
}
if (instructions) {
body.instructions = instructions;
}
@@ -333,12 +338,12 @@ const chatV1 = async (req, res) => {
};
const addVisionPrompt = async () => {
if (!req.body.endpointOption.attachments) {
if (!endpointOption.attachments) {
return;
}
/** @type {MongoFile[]} */
const attachments = await req.body.endpointOption.attachments;
const attachments = await endpointOption.attachments;
if (attachments && attachments.every((attachment) => checkOpenAIStorage(attachment.source))) {
return;
}

View File

@@ -53,6 +53,7 @@ const chatV2 = async (req, res) => {
promptPrefix,
assistant_id,
instructions,
endpointOption,
thread_id: _thread_id,
messageId: _messageId,
conversationId: convoId,
@@ -160,7 +161,7 @@ const chatV2 = async (req, res) => {
const { openai: _openai, client } = await getOpenAIClient({
req,
res,
endpointOption: req.body.endpointOption,
endpointOption,
initAppClient: true,
});
@@ -194,6 +195,10 @@ const chatV2 = async (req, res) => {
body.additional_instructions = promptPrefix;
}
if (typeof endpointOption.artifactsPrompt === 'string' && endpointOption.artifactsPrompt) {
body.additional_instructions = `${body.additional_instructions ?? ''}\n${endpointOption.artifactsPrompt}`.trim();
}
if (instructions) {
body.instructions = instructions;
}

View File

@@ -4,6 +4,7 @@ require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const compression = require('compression');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
const { jwtLogin, passportLogin } = require('~/strategies');
@@ -15,10 +16,11 @@ const validateImageRequest = require('./middleware/validateImageRequest');
const errorController = require('./controllers/ErrorController');
const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN } = process.env ?? {};
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
const port = Number(PORT) || 3080;
const host = HOST || 'localhost';
@@ -37,30 +39,34 @@ const startServer = async () => {
app.get('/health', (_req, res) => res.status(200).send('OK'));
// Middleware
/* Middleware */
app.use(noIndex);
app.use(errorController);
app.use(express.json({ limit: '3mb' }));
app.use(mongoSanitize());
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(express.static(app.locals.paths.dist));
app.use(express.static(app.locals.paths.fonts));
app.use(express.static(app.locals.paths.assets));
app.set('trust proxy', 1); // trust first proxy
app.use(staticCache(app.locals.paths.dist));
app.use(staticCache(app.locals.paths.fonts));
app.use(staticCache(app.locals.paths.assets));
app.set('trust proxy', 1); /* trust first proxy */
app.use(cors());
if (!isEnabled(DISABLE_COMPRESSION)) {
app.use(compression());
}
if (!ALLOW_SOCIAL_LOGIN) {
console.warn(
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
);
}
// OAUTH
/* OAUTH */
app.use(passport.initialize());
passport.use(await jwtLogin());
passport.use(passportLogin());
// LDAP Auth
/* LDAP Auth */
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
passport.use(ldapLogin);
}
@@ -70,7 +76,7 @@ const startServer = async () => {
}
app.use('/oauth', routes.oauth);
// API Endpoints
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);

View File

@@ -2,9 +2,9 @@ const { isAssistantsEndpoint } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { spendTokens } = require('~/models/spendTokens');
const abortControllers = require('./abortControllers');
const { saveMessage, getConvo } = require('~/models');
const spendTokens = require('~/models/spendTokens');
const { abortRun } = require('./abortRun');
const { logger } = require('~/config');

View File

@@ -1,4 +1,4 @@
const { parseConvo, EModelEndpoint } = require('librechat-data-provider');
const { parseCompactConvo, EModelEndpoint } = require('librechat-data-provider');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
const assistants = require('~/server/services/Endpoints/assistants');
@@ -24,7 +24,7 @@ const buildFunction = {
async function buildEndpointOption(req, res, next) {
const { endpoint, endpointType } = req.body;
const parsedBody = parseConvo({ endpoint, endpointType, conversation: req.body });
const parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body });
if (req.app.locals.modelSpecs?.list && req.app.locals.modelSpecs?.enforce) {
/** @type {{ list: TModelSpec[] }}*/

View File

@@ -0,0 +1,27 @@
const { getInvite } = require('~/models/inviteUser');
const { deleteTokens } = require('~/models/Token');
async function checkInviteUser(req, res, next) {
const token = req.body.token;
if (!token || token === 'undefined') {
next();
return;
}
try {
const invite = await getInvite(token, req.body.email);
if (!invite || invite.error === true) {
return res.status(400).json({ message: 'Invalid invite token' });
}
await deleteTokens({ token: invite.token });
req.invite = invite;
next();
} catch (error) {
return res.status(429).json({ message: error.message });
}
}
module.exports = checkInviteUser;

View File

@@ -1,5 +1,7 @@
const clearPendingReq = require('../../cache/clearPendingReq');
const { logViolation, getLogStores } = require('../../cache');
const { Time } = require('librechat-data-provider');
const clearPendingReq = require('~/cache/clearPendingReq');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const denyRequest = require('./denyRequest');
const {
@@ -7,7 +9,6 @@ const {
CONCURRENT_MESSAGE_MAX = 1,
CONCURRENT_VIOLATION_SCORE: score,
} = process.env ?? {};
const ttl = 1000 * 60 * 1;
/**
* Middleware to limit concurrent requests for a user.
@@ -38,7 +39,7 @@ const concurrentLimiter = async (req, res, next) => {
const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1);
const type = 'concurrent';
const key = `${USE_REDIS ? namespace : ''}:${userId}`;
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`;
const pendingRequests = +((await cache.get(key)) ?? 0);
if (pendingRequests >= limit) {
@@ -51,7 +52,7 @@ const concurrentLimiter = async (req, res, next) => {
await logViolation(req, res, type, errorMessage, score);
return await denyRequest(req, res, errorMessage);
} else {
await cache.set(key, pendingRequests + 1, ttl);
await cache.set(key, pendingRequests + 1, Time.ONE_MINUTE);
}
// Ensure the requests are removed from the store once the request is done

View File

@@ -10,10 +10,12 @@ const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
const setHeaders = require('./setHeaders');
const validate = require('./validate');
const limiters = require('./limiters');
const uaParser = require('./uaParser');
const checkBan = require('./checkBan');
@@ -22,6 +24,7 @@ const roles = require('./roles');
module.exports = {
...abortMiddleware,
...validate,
...limiters,
...roles,
noIndex,
@@ -31,6 +34,7 @@ module.exports = {
moderateText,
validateModel,
requireJwtAuth,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
canDeleteAccount,

View File

@@ -0,0 +1,112 @@
const jwt = require('jsonwebtoken');
const validateImageRequest = require('~/server/middleware/validateImageRequest');
describe('validateImageRequest middleware', () => {
let req, res, next;
beforeEach(() => {
req = {
app: { locals: { secureImageLinks: true } },
headers: {},
originalUrl: '',
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
});
afterEach(() => {
jest.clearAllMocks();
});
test('should call next() if secureImageLinks is false', () => {
req.app.locals.secureImageLinks = false;
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 401 if refresh token is not provided', () => {
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
test('should return 403 if refresh token is invalid', () => {
req.headers.cookie = 'refreshToken=invalid-token';
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 if refresh token is expired', () => {
const expiredToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${expiredToken}`;
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should call next() for valid image path', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/example.jpg';
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid image path', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/456/example.jpg';
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
// File traversal tests
test('should prevent file traversal attempts', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
const traversalAttempts = [
'/images/123/../../../etc/passwd',
'/images/123/..%2F..%2F..%2Fetc%2Fpasswd',
'/images/123/image.jpg/../../../etc/passwd',
'/images/123/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd',
];
traversalAttempts.forEach((attempt) => {
req.originalUrl = attempt;
validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks();
});
});
test('should handle URL encoded characters in valid paths', () => {
const validToken = jwt.sign(
{ id: '123', exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/123/image%20with%20spaces.jpg';
validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const { searchConversation } = require('~/models/Conversation');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { isEnabled } = require('~/server/utils');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
/**
* Middleware to validate user's authorization for a conversation.
*
* This middleware checks if a user has the right to access a specific conversation.
* If the user doesn't have access, an error is returned. If the conversation doesn't exist,
* a not found error is returned. If the access is valid, the middleware allows the request to proceed.
* If the `cache` store is not available, the middleware will skip its logic.
*
* @function
* @param {Express.Request} req - Express request object containing user information.
* @param {Express.Response} res - Express response object.
* @param {function} next - Express next middleware function.
* @throws {Error} Throws an error if the user doesn't have access to the conversation.
*/
const validateConvoAccess = async (req, res, next) => {
const namespace = ViolationTypes.CONVO_ACCESS;
const cache = getLogStores(namespace);
const conversationId = req.body.conversationId;
if (!conversationId || conversationId === Constants.NEW_CONVO) {
return next();
}
const userId = req.user?.id ?? req.user?._id ?? '';
const type = ViolationTypes.CONVO_ACCESS;
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}:${conversationId}`;
try {
if (cache) {
const cachedAccess = await cache.get(key);
if (cachedAccess === 'authorized') {
return next();
}
}
const conversation = await searchConversation(conversationId);
if (!conversation) {
return next();
}
if (conversation.user !== userId) {
const errorMessage = {
type,
error: 'User not authorized for this conversation',
};
if (cache) {
await logViolation(req, res, type, errorMessage, score);
}
return await denyRequest(req, res, errorMessage);
}
if (cache) {
await cache.set(key, 'authorized', Time.TEN_MINUTES);
}
next();
} catch (error) {
console.error('Error validating conversation access:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
module.exports = validateConvoAccess;

View File

@@ -0,0 +1,4 @@
const validateConvoAccess = require('./convoAccess');
module.exports = {
validateConvoAccess,
};

View File

@@ -1,10 +1,16 @@
const { isEnabled } = require('~/server/utils');
function validateRegistration(req, res, next) {
if (req.invite) {
return next();
}
if (isEnabled(process.env.ALLOW_REGISTRATION)) {
next();
} else {
res.status(403).send('Registration is not allowed.');
return res.status(403).json({
message: 'Registration is not allowed.',
});
}
}

View File

@@ -12,9 +12,10 @@ const {
uaParser,
checkBan,
requireJwtAuth,
concurrentLimiter,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@@ -37,6 +38,8 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use(validateConvoAccess);
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
router.use(`/${EModelEndpoint.chatGPTBrowser}`, askChatGPTBrowser);
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);

View File

@@ -42,7 +42,7 @@ router.post('/:assistant_id', async (req, res) => {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = encryptMetadata(_metadata);
let metadata = await encryptMetadata(_metadata);
let { domain } = metadata;
domain = await domainParser(req, domain, true);

View File

@@ -8,6 +8,7 @@ const {
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
const validateAssistant = require('~/server/middleware/assistants/validate');
const chatController = require('~/server/controllers/assistants/chatV1');
@@ -21,6 +22,14 @@ router.post('/abort', handleAbort());
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', validateModel, buildEndpointOption, validateAssistant, setHeaders, chatController);
router.post(
'/',
validateModel,
buildEndpointOption,
validateAssistant,
validateConvoAccess,
setHeaders,
chatController,
);
module.exports = router;

View File

@@ -8,6 +8,7 @@ const {
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const validateConvoAccess = require('~/server/middleware/validate/convoAccess');
const validateAssistant = require('~/server/middleware/assistants/validate');
const chatController = require('~/server/controllers/assistants/chatV2');
@@ -21,6 +22,14 @@ router.post('/abort', handleAbort());
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', validateModel, buildEndpointOption, validateAssistant, setHeaders, chatController);
router.post(
'/',
validateModel,
buildEndpointOption,
validateAssistant,
validateConvoAccess,
setHeaders,
chatController,
);
module.exports = router;

View File

@@ -1,13 +1,6 @@
const express = require('express');
const router = express.Router();
const {
uaParser,
checkBan,
requireJwtAuth,
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
} = require('~/server/middleware');
const { uaParser, checkBan, requireJwtAuth } = require('~/server/middleware');
const v1 = require('./v1');
const chatV1 = require('./chatV1');

View File

@@ -11,6 +11,7 @@ const {
checkBan,
loginLimiter,
requireJwtAuth,
checkInviteUser,
registerLimiter,
requireLdapAuth,
requireLocalAuth,
@@ -32,7 +33,14 @@ router.post(
loginController,
);
router.post('/refresh', refreshController);
router.post('/register', registerLimiter, checkBan, validateRegistration, registrationController);
router.post(
'/register',
registerLimiter,
checkBan,
checkInviteUser,
validateRegistration,
registrationController,
);
router.post(
'/requestPasswordReset',
resetPasswordLimiter,

View File

@@ -8,7 +8,6 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { forkConversation } = require('~/server/utils/import/fork');
const { importConversations } = require('~/server/utils/import');
const { createImportLimiters } = require('~/server/middleware');
const { updateTagsForConversation } = require('~/models/ConversationTag');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
@@ -31,11 +30,12 @@ router.get('/', async (req, res) => {
return res.status(400).json({ error: 'Invalid page size' });
}
const isArchived = req.query.isArchived === 'true';
const tags = req.query.tags
? Array.isArray(req.query.tags)
? req.query.tags
: [req.query.tags]
: undefined;
let tags;
if (req.query.tags) {
tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags];
} else {
tags = undefined;
}
res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived, tags));
});
@@ -173,9 +173,4 @@ router.post('/fork', async (req, res) => {
}
});
router.put('/tags/:conversationId', async (req, res) => {
const tag = await updateTagsForConversation(req.user.id, req.params.conversationId, req.body);
res.status(200).json(tag);
});
module.exports = router;

View File

@@ -13,6 +13,7 @@ const {
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@@ -35,6 +36,8 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use(validateConvoAccess);
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);
router.use(`/${EModelEndpoint.anthropic}`, anthropic);

View File

@@ -1,13 +1,18 @@
const fs = require('fs').promises;
const express = require('express');
const { isUUID, checkOpenAIStorage } = require('librechat-data-provider');
const {
isUUID,
checkOpenAIStorage,
FileSources,
EModelEndpoint,
} = require('librechat-data-provider');
const {
filterFile,
processFileUpload,
processDeleteRequest,
} = require('~/server/services/Files/process');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
@@ -113,7 +118,15 @@ router.get('/download/:userId/:file_id', async (req, res) => {
if (checkOpenAIStorage(file.source)) {
req.body = { model: file.model };
const { openai } = await initializeClient({ req, res });
const endpointMap = {
[FileSources.openai]: EModelEndpoint.assistants,
[FileSources.azure]: EModelEndpoint.azureAssistants,
};
const { openai } = await getOpenAIClient({
req,
res,
overrideEndpoint: endpointMap[file.source],
});
logger.debug(`Downloading file ${file_id} from OpenAI`);
passThrough = await getDownloadStream(file_id, openai);
setHeaders();

View File

@@ -1,7 +1,8 @@
const express = require('express');
const staticCache = require('../utils/staticCache');
const paths = require('~/config/paths');
const router = express.Router();
router.use(express.static(paths.imageOutput));
router.use(staticCache(paths.imageOutput));
module.exports = router;

View File

@@ -1,44 +1,116 @@
const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getConversationTags,
updateConversationTag,
createConversationTag,
deleteConversationTag,
rebuildConversationTags,
updateTagsForConversation,
} = require('~/models/ConversationTag');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const router = express.Router();
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]);
router.use(requireJwtAuth);
router.use(checkBookmarkAccess);
/**
* GET /
* Retrieves all conversation tags for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.get('/', async (req, res) => {
const tags = await getConversationTags(req.user.id);
if (tags) {
res.status(200).json(tags);
} else {
res.status(404).end();
try {
const tags = await getConversationTags(req.user.id);
if (tags) {
res.status(200).json(tags);
} else {
res.status(404).end();
}
} catch (error) {
logger.error('Error getting conversation tags:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /
* Creates a new conversation tag for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.post('/', async (req, res) => {
const tag = await createConversationTag(req.user.id, req.body);
res.status(200).json(tag);
});
router.post('/rebuild', async (req, res) => {
const tag = await rebuildConversationTags(req.user.id);
res.status(200).json(tag);
try {
const tag = await createConversationTag(req.user.id, req.body);
res.status(200).json(tag);
} catch (error) {
logger.error('Error creating conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /:tag
* Updates an existing conversation tag for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/:tag', async (req, res) => {
const tag = await updateConversationTag(req.user.id, req.params.tag, req.body);
res.status(200).json(tag);
try {
const tag = await updateConversationTag(req.user.id, req.params.tag, req.body);
if (tag) {
res.status(200).json(tag);
} else {
res.status(404).json({ error: 'Tag not found' });
}
} catch (error) {
logger.error('Error updating conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* DELETE /:tag
* Deletes a conversation tag for the authenticated user.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.delete('/:tag', async (req, res) => {
const tag = await deleteConversationTag(req.user.id, req.params.tag);
res.status(200).json(tag);
try {
const tag = await deleteConversationTag(req.user.id, req.params.tag);
if (tag) {
res.status(200).json(tag);
} else {
res.status(404).json({ error: 'Tag not found' });
}
} catch (error) {
logger.error('Error deleting conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /convo/:conversationId
* Updates the tags for a conversation.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/convo/:conversationId', async (req, res) => {
try {
const conversationTags = await updateTagsForConversation(
req.user.id,
req.params.conversationId,
req.body.tags,
);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);
res.status(500).send('Error updating conversation tags');
}
});
module.exports = router;

View File

@@ -116,8 +116,8 @@ async function loadActionSets(searchParams) {
* @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call.
* @returns { { _call: (toolInput: Object) => unknown} } An object with `_call` method to execute the tool input.
*/
function createActionTool({ action, requestBuilder }) {
action.metadata = decryptMetadata(action.metadata);
async function createActionTool({ action, requestBuilder }) {
action.metadata = await decryptMetadata(action.metadata);
const _call = async (toolInput) => {
try {
requestBuilder.setParams(toolInput);
@@ -153,23 +153,23 @@ function createActionTool({ action, requestBuilder }) {
* @param {ActionMetadata} metadata - The action metadata to encrypt.
* @returns {ActionMetadata} The updated action metadata with encrypted values.
*/
function encryptMetadata(metadata) {
async function encryptMetadata(metadata) {
const encryptedMetadata = { ...metadata };
// ServiceHttp
if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
if (metadata.api_key) {
encryptedMetadata.api_key = encryptV2(metadata.api_key);
encryptedMetadata.api_key = await encryptV2(metadata.api_key);
}
}
// OAuth
else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
if (metadata.oauth_client_id) {
encryptedMetadata.oauth_client_id = encryptV2(metadata.oauth_client_id);
encryptedMetadata.oauth_client_id = await encryptV2(metadata.oauth_client_id);
}
if (metadata.oauth_client_secret) {
encryptedMetadata.oauth_client_secret = encryptV2(metadata.oauth_client_secret);
encryptedMetadata.oauth_client_secret = await encryptV2(metadata.oauth_client_secret);
}
}
@@ -182,23 +182,23 @@ function encryptMetadata(metadata) {
* @param {ActionMetadata} metadata - The action metadata to decrypt.
* @returns {ActionMetadata} The updated action metadata with decrypted values.
*/
function decryptMetadata(metadata) {
async function decryptMetadata(metadata) {
const decryptedMetadata = { ...metadata };
// ServiceHttp
if (metadata.auth && metadata.auth.type === AuthTypeEnum.ServiceHttp) {
if (metadata.api_key) {
decryptedMetadata.api_key = decryptV2(metadata.api_key);
decryptedMetadata.api_key = await decryptV2(metadata.api_key);
}
}
// OAuth
else if (metadata.auth && metadata.auth.type === AuthTypeEnum.OAuth) {
if (metadata.oauth_client_id) {
decryptedMetadata.oauth_client_id = decryptV2(metadata.oauth_client_id);
decryptedMetadata.oauth_client_id = await decryptV2(metadata.oauth_client_id);
}
if (metadata.oauth_client_secret) {
decryptedMetadata.oauth_client_secret = decryptV2(metadata.oauth_client_secret);
decryptedMetadata.oauth_client_secret = await decryptV2(metadata.oauth_client_secret);
}
}

View File

@@ -0,0 +1,87 @@
jest.mock('~/models/Role', () => ({
initializeRoles: jest.fn(),
updateAccessPermissions: jest.fn(),
getRoleByName: jest.fn(),
updateRoleByName: jest.fn(),
}));
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('./Config/loadCustomConfig', () => jest.fn());
jest.mock('./start/interface', () => ({
loadDefaultInterface: jest.fn(),
}));
jest.mock('./ToolService', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({}),
}));
jest.mock('./start/checks', () => ({
checkVariables: jest.fn(),
checkHealth: jest.fn(),
checkConfig: jest.fn(),
checkAzureVariables: jest.fn(),
}));
const AppService = require('./AppService');
const { loadDefaultInterface } = require('./start/interface');
describe('AppService interface configuration', () => {
let app;
let mockLoadCustomConfig;
beforeEach(() => {
app = { locals: {} };
jest.resetModules();
jest.clearAllMocks();
mockLoadCustomConfig = require('./Config/loadCustomConfig');
});
it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => {
mockLoadCustomConfig.mockResolvedValue({});
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true });
await AppService(app);
expect(app.locals.interfaceConfig.prompts).toBe(true);
expect(app.locals.interfaceConfig.bookmarks).toBe(true);
expect(loadDefaultInterface).toHaveBeenCalled();
});
it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => {
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } });
loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false });
await AppService(app);
expect(app.locals.interfaceConfig.prompts).toBe(false);
expect(app.locals.interfaceConfig.bookmarks).toBe(false);
expect(loadDefaultInterface).toHaveBeenCalled();
});
it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => {
mockLoadCustomConfig.mockResolvedValue({});
loadDefaultInterface.mockResolvedValue({});
await AppService(app);
expect(app.locals.interfaceConfig.prompts).toBeUndefined();
expect(app.locals.interfaceConfig.bookmarks).toBeUndefined();
expect(loadDefaultInterface).toHaveBeenCalled();
});
it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => {
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } });
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false });
await AppService(app);
expect(app.locals.interfaceConfig.prompts).toBe(true);
expect(app.locals.interfaceConfig.bookmarks).toBe(false);
expect(loadDefaultInterface).toHaveBeenCalled();
});
});

View File

@@ -45,7 +45,7 @@ const AppService = async (app) => {
const socialLogins =
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
const interfaceConfig = loadDefaultInterface(config, configDefaults);
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
const defaultLocals = {
paths,

View File

@@ -23,6 +23,7 @@ jest.mock('./Files/Firebase/initialize', () => ({
}));
jest.mock('~/models/Role', () => ({
initializeRoles: jest.fn(),
updateAccessPermissions: jest.fn(),
}));
jest.mock('./ToolService', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({
@@ -97,8 +98,6 @@ describe('AppService', () => {
socialLogins: ['testLogin'],
fileStrategy: 'testStrategy',
interfaceConfig: expect.objectContaining({
privacyPolicy: undefined,
termsOfService: undefined,
endpointsMenu: true,
modelSelect: true,
parameters: true,

View File

@@ -1,5 +1,5 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { webcrypto } = require('node:crypto');
const { SystemRoles, errorsToString } = require('librechat-data-provider');
const {
findUser,
@@ -10,11 +10,11 @@ const {
generateToken,
deleteUserById,
} = require('~/models/userMethods');
const { createToken, findToken, deleteTokens, Session } = require('~/models');
const { sendEmail, checkEmailConfig } = require('~/server/utils');
const { registerSchema } = require('~/strategies/validators');
const { hashToken } = require('~/server/utils/crypto');
const isDomainAllowed = require('./isDomainAllowed');
const Token = require('~/models/schema/tokenSchema');
const Session = require('~/models/Session');
const { logger } = require('~/config');
const domains = {
@@ -34,7 +34,7 @@ const genericVerificationMessage = 'Please check your email to verify your email
*/
const logoutUser = async (userId, refreshToken) => {
try {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const hash = await hashToken(refreshToken);
// Find the session with the matching user and refreshTokenHash
const session = await Session.findOne({ user: userId, refreshTokenHash: hash });
@@ -53,14 +53,23 @@ const logoutUser = async (userId, refreshToken) => {
}
};
/**
* Creates Token and corresponding Hash for verification
* @returns {[string, string]}
*/
const createTokenHash = () => {
const token = Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString('hex');
const hash = bcrypt.hashSync(token, 10);
return [token, hash];
};
/**
* Send Verification Email
* @param {Partial<MongoUser> & { _id: ObjectId, email: string, name: string}} user
* @returns {Promise<void>}
*/
const sendVerificationEmail = async (user) => {
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
@@ -77,12 +86,13 @@ const sendVerificationEmail = async (user) => {
template: 'verifyEmail.handlebars',
});
await new Token({
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
}).save();
expiresIn: 900,
});
logger.info(`[sendVerificationEmail] Verification link issued. [Email: ${user.email}]`);
};
@@ -93,7 +103,7 @@ const sendVerificationEmail = async (user) => {
*/
const verifyEmail = async (req) => {
const { email, token } = req.body;
let emailVerificationData = await Token.findOne({ email: decodeURIComponent(email) });
let emailVerificationData = await findToken({ email: decodeURIComponent(email) });
if (!emailVerificationData) {
logger.warn(`[verifyEmail] [No email verification data found] [Email: ${email}]`);
@@ -113,7 +123,7 @@ const verifyEmail = async (req) => {
return new Error('User not found');
}
await emailVerificationData.deleteOne();
await deleteTokens({ token: emailVerificationData.token });
logger.info(`[verifyEmail] Email verification successful. [Email: ${email}]`);
return { message: 'Email verification was successful' };
};
@@ -221,19 +231,16 @@ const requestPasswordReset = async (req) => {
};
}
let token = await Token.findOne({ userId: user._id });
if (token) {
await token.deleteOne();
}
await deleteTokens({ userId: user._id });
let resetToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(resetToken, 10);
const [resetToken, hash] = createTokenHash();
await new Token({
await createToken({
userId: user._id,
token: hash,
createdAt: Date.now(),
}).save();
expiresIn: 900,
});
const link = `${domains.client}/reset-password?token=${resetToken}&userId=${user._id}`;
@@ -273,7 +280,9 @@ const requestPasswordReset = async (req) => {
* @returns
*/
const resetPassword = async (userId, token, password) => {
let passwordResetToken = await Token.findOne({ userId });
let passwordResetToken = await findToken({
userId,
});
if (!passwordResetToken) {
return new Error('Invalid or expired password reset token');
@@ -301,7 +310,7 @@ const resetPassword = async (userId, token, password) => {
});
}
await passwordResetToken.deleteOne();
await deleteTokens({ token: passwordResetToken.token });
logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`);
return { message: 'Password reset was successful' };
};
@@ -357,7 +366,7 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
const resendVerificationEmail = async (req) => {
try {
const { email } = req.body;
await Token.deleteMany({ email });
await deleteTokens(email);
const user = await findUser({ email }, 'email _id name');
if (!user) {
@@ -365,8 +374,7 @@ const resendVerificationEmail = async (req) => {
return { status: 200, message: genericVerificationMessage };
}
let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10);
const [verifyToken, hash] = createTokenHash();
const verificationLink = `${
domains.client
@@ -384,12 +392,13 @@ const resendVerificationEmail = async (req) => {
template: 'verifyEmail.handlebars',
});
await new Token({
await createToken({
userId: user._id,
email: user.email,
token: hash,
createdAt: Date.now(),
}).save();
expiresIn: 900,
});
logger.info(`[resendVerificationEmail] Verification link issued. [Email: ${user.email}]`);

View File

@@ -15,7 +15,7 @@ const getLdapConfig = () => {
if (ldapLoginUsesUsername) {
ldap.username = true;
}
return ldap;
};

View File

@@ -21,7 +21,11 @@ const addTitle = async (req, { text, response, client }) => {
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
const title = await client.titleConvo({ text, responseText: response?.text });
const title = await client.titleConvo({
text,
responseText: response?.text,
conversationId: response.conversationId,
});
await titleCache.set(key, title, 120000);
await saveConvo(
req,

View File

@@ -1,27 +1,36 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
modelLabel,
promptPrefix,
maxContextTokens,
resendFiles,
resendFiles = true,
promptCache = true,
iconURL,
greeting,
spec,
...rest
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = {
const endpointOption = removeNullishValues({
endpoint,
modelLabel,
promptPrefix,
resendFiles,
promptCache,
iconURL,
greeting,
spec,
maxContextTokens,
modelOptions: {
...rest,
},
};
modelOptions,
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -1,17 +1,23 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
// eslint-disable-next-line no-unused-vars
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...rest } = parsedBody;
const endpointOption = {
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
parsedBody;
const endpointOption = removeNullishValues({
endpoint,
promptPrefix,
assistant_id,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},
};
modelOptions,
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -1,17 +1,23 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
// eslint-disable-next-line no-unused-vars
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...rest } = parsedBody;
const endpointOption = {
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
parsedBody;
const endpointOption = removeNullishValues({
endpoint,
promptPrefix,
assistant_id,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},
};
modelOptions,
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -1,16 +1,20 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody, endpointType) => {
const {
chatGptLabel,
promptPrefix,
maxContextTokens,
resendFiles,
resendFiles = true,
imageDetail,
iconURL,
greeting,
spec,
...rest
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = {
const endpointOption = removeNullishValues({
endpoint,
endpointType,
chatGptLabel,
@@ -21,10 +25,12 @@ const buildOptions = (endpoint, parsedBody, endpointType) => {
greeting,
spec,
maxContextTokens,
modelOptions: {
...rest,
},
};
modelOptions,
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -47,7 +47,11 @@ const addTitle = async (req, { text, response, client }) => {
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
const title = await titleClient.titleConvo({ text, responseText: response?.text });
const title = await titleClient.titleConvo({
text,
responseText: response?.text,
conversationId: response.conversationId,
});
await titleCache.set(key, title, 120000);
await saveConvo(
req,

View File

@@ -1,17 +1,33 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const { examples, modelLabel, promptPrefix, iconURL, greeting, spec, ...rest } = parsedBody;
const endpointOption = {
const {
examples,
endpoint,
modelLabel,
resendFiles = true,
promptPrefix,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},
};
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = removeNullishValues({
examples,
endpoint,
modelLabel,
resendFiles,
promptPrefix,
iconURL,
greeting,
spec,
modelOptions,
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -1,3 +1,6 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
chatGptLabel,
@@ -8,9 +11,10 @@ const buildOptions = (endpoint, parsedBody) => {
greeting,
spec,
maxContextTokens,
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = {
const endpointOption = removeNullishValues({
endpoint,
tools:
tools
@@ -24,7 +28,11 @@ const buildOptions = (endpoint, parsedBody) => {
spec,
maxContextTokens,
modelOptions,
};
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -21,7 +21,11 @@ const addTitle = async (req, { text, response, client }) => {
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
const title = await client.titleConvo({ text, responseText: response?.text });
const title = await client.titleConvo({
text,
responseText: response?.text,
conversationId: response.conversationId,
});
await titleCache.set(key, title, 120000);
await saveConvo(
req,

View File

@@ -1,16 +1,21 @@
const { removeNullishValues } = require('librechat-data-provider');
const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const buildOptions = (endpoint, parsedBody) => {
const {
chatGptLabel,
promptPrefix,
maxContextTokens,
resendFiles,
resendFiles = true,
imageDetail,
iconURL,
greeting,
spec,
...rest
artifacts,
...modelOptions
} = parsedBody;
const endpointOption = {
const endpointOption = removeNullishValues({
endpoint,
chatGptLabel,
promptPrefix,
@@ -20,10 +25,12 @@ const buildOptions = (endpoint, parsedBody) => {
greeting,
spec,
maxContextTokens,
modelOptions: {
...rest,
},
};
modelOptions,
});
if (typeof artifacts === 'string') {
endpointOption.artifactsPrompt = generateArtifactsPrompt({ endpoint, artifacts });
}
return endpointOption;
};

View File

@@ -0,0 +1,248 @@
const { Readable } = require('stream');
const axios = require('axios');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { genAzureEndpoint } = require('~/utils');
const { logger } = require('~/config');
/**
* Service class for handling Speech-to-Text (STT) operations.
* @class
*/
class STTService {
/**
* Creates an instance of STTService.
* @param {Object} customConfig - The custom configuration object.
*/
constructor(customConfig) {
this.customConfig = customConfig;
this.providerStrategies = {
[STTProviders.OPENAI]: this.openAIProvider,
[STTProviders.AZURE_OPENAI]: this.azureOpenAIProvider,
};
}
/**
* Creates a singleton instance of STTService.
* @static
* @async
* @returns {Promise<STTService>} The STTService instance.
* @throws {Error} If the custom config is not found.
*/
static async getInstance() {
const customConfig = await getCustomConfig();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new STTService(customConfig);
}
/**
* Retrieves the configured STT provider and its schema.
* @returns {Promise<[string, Object]>} A promise that resolves to an array containing the provider name and its schema.
* @throws {Error} If no STT schema is set, multiple providers are set, or no provider is set.
*/
async getProviderSchema() {
const sttSchema = this.customConfig.speech.stt;
if (!sttSchema) {
throw new Error(
'No STT schema is set. Did you configure STT in the custom config (librechat.yaml)?',
);
}
const providers = Object.entries(sttSchema).filter(
([, value]) => Object.keys(value).length > 0,
);
if (providers.length !== 1) {
throw new Error(
providers.length > 1
? 'Multiple providers are set. Please set only one provider.'
: 'No provider is set. Please set a provider.',
);
}
const [provider, schema] = providers[0];
return [provider, schema];
}
/**
* Recursively removes undefined properties from an object.
* @param {Object} obj - The object to clean.
* @returns {void}
*/
removeUndefined(obj) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
this.removeUndefined(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
} else if (obj[key] === undefined) {
delete obj[key];
}
});
}
/**
* Prepares the request for the OpenAI STT provider.
* @param {Object} sttSchema - The STT schema for OpenAI.
* @param {Stream} audioReadStream - The audio data to be transcribed.
* @returns {Array} An array containing the URL, data, and headers for the request.
*/
openAIProvider(sttSchema, audioReadStream) {
const url = sttSchema?.url || 'https://api.openai.com/v1/audio/transcriptions';
const apiKey = extractEnvVariable(sttSchema.apiKey) || '';
const data = {
file: audioReadStream,
model: sttSchema.model,
};
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
};
[headers].forEach(this.removeUndefined);
return [url, data, headers];
}
/**
* Prepares the request for the Azure OpenAI STT provider.
* @param {Object} sttSchema - The STT schema for Azure OpenAI.
* @param {Buffer} audioBuffer - The audio data to be transcribed.
* @param {Object} audioFile - The audio file object containing originalname, mimetype, and size.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the audio file size exceeds 25MB or the audio file format is not accepted.
*/
azureOpenAIProvider(sttSchema, audioBuffer, audioFile) {
const url = `${genAzureEndpoint({
azureOpenAIApiInstanceName: sttSchema?.instanceName,
azureOpenAIApiDeploymentName: sttSchema?.deploymentName,
})}/audio/transcriptions?api-version=${sttSchema?.apiVersion}`;
const apiKey = sttSchema.apiKey ? extractEnvVariable(sttSchema.apiKey) : '';
if (audioBuffer.byteLength > 25 * 1024 * 1024) {
throw new Error('The audio file size exceeds the limit of 25MB');
}
const acceptedFormats = ['flac', 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'ogg', 'wav', 'webm'];
const fileFormat = audioFile.mimetype.split('/')[1];
if (!acceptedFormats.includes(fileFormat)) {
throw new Error(`The audio file format ${fileFormat} is not accepted`);
}
const formData = new FormData();
const audioBlob = new Blob([audioBuffer], { type: audioFile.mimetype });
formData.append('file', audioBlob, audioFile.originalname);
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { 'api-key': apiKey }),
};
[headers].forEach(this.removeUndefined);
return [url, formData, headers];
}
/**
* Sends an STT request to the specified provider.
* @async
* @param {string} provider - The STT provider to use.
* @param {Object} sttSchema - The STT schema for the provider.
* @param {Object} requestData - The data required for the STT request.
* @param {Buffer} requestData.audioBuffer - The audio data to be transcribed.
* @param {Object} requestData.audioFile - The audio file object containing originalname, mimetype, and size.
* @returns {Promise<string>} A promise that resolves to the transcribed text.
* @throws {Error} If the provider is invalid, the response status is not 200, or the response data is missing.
*/
async sttRequest(provider, sttSchema, { audioBuffer, audioFile }) {
const strategy = this.providerStrategies[provider];
if (!strategy) {
throw new Error('Invalid provider');
}
const audioReadStream = Readable.from(audioBuffer);
audioReadStream.path = 'audio.wav';
const [url, data, headers] = strategy.call(this, sttSchema, audioReadStream, audioFile);
if (!Readable.from && data instanceof FormData) {
const audioBlob = new Blob([audioBuffer], { type: audioFile.mimetype });
data.set('file', audioBlob, audioFile.originalname);
}
try {
const response = await axios.post(url, data, { headers });
if (response.status !== 200) {
throw new Error('Invalid response from the STT API');
}
if (!response.data || !response.data.text) {
throw new Error('Missing data in response from the STT API');
}
return response.data.text.trim();
} catch (error) {
logger.error(`STT request failed for provider ${provider}:`, error);
throw error;
}
}
/**
* Processes a speech-to-text request.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async processTextToSpeech(req, res) {
if (!req.file || !req.file.buffer) {
return res.status(400).json({ message: 'No audio file provided in the FormData' });
}
const audioBuffer = req.file.buffer;
const audioFile = {
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
};
try {
const [provider, sttSchema] = await this.getProviderSchema();
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile });
res.json({ text });
} catch (error) {
logger.error('An error occurred while processing the audio:', error);
res.sendStatus(500);
}
}
}
/**
* Factory function to create an STTService instance.
* @async
* @returns {Promise<STTService>} A promise that resolves to an STTService instance.
*/
async function createSTTService() {
return STTService.getInstance();
}
/**
* Wrapper function for speech-to-text processing.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async function speechToText(req, res) {
const sttService = await createSTTService();
await sttService.processTextToSpeech(req, res);
}
module.exports = { speechToText };

View File

@@ -0,0 +1,477 @@
const axios = require('axios');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { logger } = require('~/config');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { genAzureEndpoint } = require('~/utils');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
/**
* Service class for handling Text-to-Speech (TTS) operations.
* @class
*/
class TTSService {
/**
* Creates an instance of TTSService.
* @param {Object} customConfig - The custom configuration object.
*/
constructor(customConfig) {
this.customConfig = customConfig;
this.providerStrategies = {
[TTSProviders.OPENAI]: this.openAIProvider.bind(this),
[TTSProviders.AZURE_OPENAI]: this.azureOpenAIProvider.bind(this),
[TTSProviders.ELEVENLABS]: this.elevenLabsProvider.bind(this),
[TTSProviders.LOCALAI]: this.localAIProvider.bind(this),
};
}
/**
* Creates a singleton instance of TTSService.
* @static
* @async
* @returns {Promise<TTSService>} The TTSService instance.
* @throws {Error} If the custom config is not found.
*/
static async getInstance() {
const customConfig = await getCustomConfig();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new TTSService(customConfig);
}
/**
* Retrieves the configured TTS provider.
* @returns {string} The name of the configured provider.
* @throws {Error} If no provider is set or multiple providers are set.
*/
getProvider() {
const ttsSchema = this.customConfig.speech.tts;
if (!ttsSchema) {
throw new Error(
'No TTS schema is set. Did you configure TTS in the custom config (librechat.yaml)?',
);
}
const providers = Object.entries(ttsSchema).filter(
([, value]) => Object.keys(value).length > 0,
);
if (providers.length !== 1) {
throw new Error(
providers.length > 1
? 'Multiple providers are set. Please set only one provider.'
: 'No provider is set. Please set a provider.',
);
}
return providers[0][0];
}
/**
* Selects a voice for TTS based on provider schema and request.
* @async
* @param {Object} providerSchema - The schema for the selected provider.
* @param {string} requestVoice - The requested voice.
* @returns {Promise<string>} The selected voice.
*/
async getVoice(providerSchema, requestVoice) {
const voices = providerSchema.voices.filter((voice) => voice && voice.toUpperCase() !== 'ALL');
let voice = requestVoice;
if (!voice || !voices.includes(voice) || (voice.toUpperCase() === 'ALL' && voices.length > 1)) {
voice = getRandomVoiceId(voices);
}
return voice;
}
/**
* Recursively removes undefined properties from an object.
* @param {Object} obj - The object to clean.
*/
removeUndefined(obj) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
this.removeUndefined(obj[key]);
if (Object.keys(obj[key]).length === 0) {
delete obj[key];
}
} else if (obj[key] === undefined) {
delete obj[key];
}
});
}
/**
* Prepares the request for OpenAI TTS provider.
* @param {Object} ttsSchema - The TTS schema for OpenAI.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
openAIProvider(ttsSchema, input, voice) {
const url = ttsSchema?.url || 'https://api.openai.com/v1/audio/speech';
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
input,
model: ttsSchema?.model,
voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
backend: ttsSchema?.backend,
};
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${extractEnvVariable(ttsSchema?.apiKey)}`,
};
return [url, data, headers];
}
/**
* Prepares the request for Azure OpenAI TTS provider.
* @param {Object} ttsSchema - The TTS schema for Azure OpenAI.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
azureOpenAIProvider(ttsSchema, input, voice) {
const url = `${genAzureEndpoint({
azureOpenAIApiInstanceName: ttsSchema?.instanceName,
azureOpenAIApiDeploymentName: ttsSchema?.deploymentName,
})}/audio/speech?api-version=${ttsSchema?.apiVersion}`;
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
model: ttsSchema?.model,
input,
voice: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
};
const headers = {
'Content-Type': 'application/json',
'api-key': ttsSchema.apiKey ? extractEnvVariable(ttsSchema.apiKey) : '',
};
return [url, data, headers];
}
/**
* Prepares the request for ElevenLabs TTS provider.
* @param {Object} ttsSchema - The TTS schema for ElevenLabs.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @param {boolean} stream - Whether to use streaming.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
elevenLabsProvider(ttsSchema, input, voice, stream) {
let url =
ttsSchema?.url ||
`https://api.elevenlabs.io/v1/text-to-speech/${voice}${stream ? '/stream' : ''}`;
if (!ttsSchema?.voices.includes(voice) && !ttsSchema?.voices.includes('ALL')) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
model_id: ttsSchema?.model,
text: input,
voice_settings: {
similarity_boost: ttsSchema?.voice_settings?.similarity_boost,
stability: ttsSchema?.voice_settings?.stability,
style: ttsSchema?.voice_settings?.style,
use_speaker_boost: ttsSchema?.voice_settings?.use_speaker_boost,
},
pronunciation_dictionary_locators: ttsSchema?.pronunciation_dictionary_locators,
};
const headers = {
'Content-Type': 'application/json',
'xi-api-key': extractEnvVariable(ttsSchema?.apiKey),
Accept: 'audio/mpeg',
};
return [url, data, headers];
}
/**
* Prepares the request for LocalAI TTS provider.
* @param {Object} ttsSchema - The TTS schema for LocalAI.
* @param {string} input - The input text.
* @param {string} voice - The selected voice.
* @returns {Array} An array containing the URL, data, and headers for the request.
* @throws {Error} If the selected voice is not available.
*/
localAIProvider(ttsSchema, input, voice) {
const url = ttsSchema?.url;
if (
ttsSchema?.voices &&
ttsSchema.voices.length > 0 &&
!ttsSchema.voices.includes(voice) &&
!ttsSchema.voices.includes('ALL')
) {
throw new Error(`Voice ${voice} is not available.`);
}
const data = {
input,
model: ttsSchema?.voices && ttsSchema.voices.length > 0 ? voice : undefined,
backend: ttsSchema?.backend,
};
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${extractEnvVariable(ttsSchema?.apiKey)}`,
};
if (extractEnvVariable(ttsSchema.apiKey) === '') {
delete headers.Authorization;
}
return [url, data, headers];
}
/**
* Sends a TTS request to the specified provider.
* @async
* @param {string} provider - The TTS provider to use.
* @param {Object} ttsSchema - The TTS schema for the provider.
* @param {Object} options - The options for the TTS request.
* @param {string} options.input - The input text.
* @param {string} options.voice - The voice to use.
* @param {boolean} [options.stream=true] - Whether to use streaming.
* @returns {Promise<Object>} The axios response object.
* @throws {Error} If the provider is invalid or the request fails.
*/
async ttsRequest(provider, ttsSchema, { input, voice, stream = true }) {
const strategy = this.providerStrategies[provider];
if (!strategy) {
throw new Error('Invalid provider');
}
const [url, data, headers] = strategy.call(this, ttsSchema, input, voice, stream);
[data, headers].forEach(this.removeUndefined.bind(this));
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
try {
return await axios.post(url, data, options);
} catch (error) {
logger.error(`TTS request failed for provider ${provider}:`, error);
throw error;
}
}
/**
* Processes a text-to-speech request.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async processTextToSpeech(req, res) {
const { input, voice: requestVoice } = req.body;
if (!input) {
return res.status(400).send('Missing text in request body');
}
try {
res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider();
const ttsSchema = this.customConfig.speech.tts[provider];
const voice = await this.getVoice(ttsSchema, requestVoice);
if (input.length < 4096) {
const response = await this.ttsRequest(provider, ttsSchema, { input, voice });
response.data.pipe(res);
return;
}
const textChunks = splitTextIntoChunks(input, 1000);
for (const chunk of textChunks) {
try {
const response = await this.ttsRequest(provider, ttsSchema, {
voice,
input: chunk.text,
stream: true,
});
logger.debug(`[textToSpeech] user: ${req?.user?.id} | writing audio stream`);
await new Promise((resolve) => {
response.data.pipe(res, { end: chunk.isFinished });
response.data.on('end', resolve);
});
if (chunk.isFinished) {
break;
}
} catch (innerError) {
logger.error('Error processing manual update:', chunk, innerError);
if (!res.headersSent) {
return res.status(500).end();
}
return;
}
}
if (!res.headersSent) {
res.end();
}
} catch (error) {
logger.error('Error creating the audio stream:', error);
if (!res.headersSent) {
return res.status(500).send('An error occurred');
}
}
}
/**
* Streams audio data from the TTS provider.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async streamAudio(req, res) {
res.setHeader('Content-Type', 'audio/mpeg');
const provider = this.getProvider();
const ttsSchema = this.customConfig.speech.tts[provider];
const voice = await this.getVoice(ttsSchema, req.body.voice);
let shouldContinue = true;
req.on('close', () => {
logger.warn('[streamAudio] Audio Stream Request closed by client');
shouldContinue = false;
});
const processChunks = createChunkProcessor(req.body.messageId);
try {
while (shouldContinue) {
const updates = await processChunks();
if (typeof updates === 'string') {
logger.error(`Error processing audio stream updates: ${updates}`);
return res.status(500).end();
}
if (updates.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 1250));
continue;
}
for (const update of updates) {
try {
const response = await this.ttsRequest(provider, ttsSchema, {
voice,
input: update.text,
stream: true,
});
if (!shouldContinue) {
break;
}
logger.debug(`[streamAudio] user: ${req?.user?.id} | writing audio stream`);
await new Promise((resolve) => {
response.data.pipe(res, { end: update.isFinished });
response.data.on('end', resolve);
});
if (update.isFinished) {
shouldContinue = false;
break;
}
} catch (innerError) {
logger.error('Error processing audio stream update:', update, innerError);
if (!res.headersSent) {
return res.status(500).end();
}
return;
}
}
if (!shouldContinue) {
break;
}
}
if (!res.headersSent) {
res.end();
}
} catch (error) {
logger.error('Failed to fetch audio:', error);
if (!res.headersSent) {
res.status(500).end();
}
}
}
}
/**
* Factory function to create a TTSService instance.
* @async
* @returns {Promise<TTSService>} A promise that resolves to a TTSService instance.
*/
async function createTTSService() {
return TTSService.getInstance();
}
/**
* Wrapper function for text-to-speech processing.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async function textToSpeech(req, res) {
const ttsService = await createTTSService();
await ttsService.processTextToSpeech(req, res);
}
/**
* Wrapper function for audio streaming.
* @async
* @param {Object} req - The request object.
* @param {Object} res - The response object.
* @returns {Promise<void>}
*/
async function streamAudio(req, res) {
const ttsService = await createTTSService();
await ttsService.streamAudio(req, res);
}
/**
* Wrapper function to get the configured TTS provider.
* @async
* @returns {Promise<string>} A promise that resolves to the name of the configured provider.
*/
async function getProvider() {
const ttsService = await createTTSService();
return ttsService.getProvider();
}
module.exports = {
textToSpeech,
streamAudio,
getProvider,
};

View File

@@ -1,4 +1,5 @@
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { logger } = require('~/config');
/**
* This function retrieves the speechTab settings from the custom configuration
@@ -15,6 +16,13 @@ const getCustomConfig = require('~/server/services/Config/getCustomConfig');
async function getCustomConfigSpeech(req, res) {
try {
const customConfig = await getCustomConfig();
if (!customConfig) {
return res.status(200).send({
message: 'not_found',
});
}
const sttExternal = !!customConfig.speech?.stt;
const ttsExternal = !!customConfig.speech?.tts;
let settings = {
@@ -22,7 +30,7 @@ async function getCustomConfigSpeech(req, res) {
ttsExternal,
};
if (!customConfig || !customConfig.speech?.speechTab) {
if (!customConfig.speech?.speechTab) {
return res.status(200).send(settings);
}
@@ -50,7 +58,7 @@ async function getCustomConfigSpeech(req, res) {
return res.status(200).send(settings);
} catch (error) {
console.error('Failed to get custom config speech settings:', error);
logger.error('Failed to get custom config speech settings:', error);
res.status(500).send('Internal Server Error');
}
}

View File

@@ -1,6 +1,6 @@
const { TTSProviders } = require('librechat-data-provider');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const { getProvider } = require('./textToSpeech');
const { getProvider } = require('./TTSService');
/**
* This function retrieves the available voices for the current TTS provider
@@ -21,7 +21,7 @@ async function getVoices(req, res) {
}
const ttsSchema = customConfig?.speech?.tts;
const provider = getProvider(ttsSchema);
const provider = await getProvider(ttsSchema);
let voices;
switch (provider) {

Some files were not shown because too many files have changed in this diff Show More