diff --git a/api/models/Prompt.js b/api/models/Prompt.js index 26e81393ef..56dcd78570 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -51,6 +51,94 @@ const createGroupPipeline = (query, skip, limit) => { ]; }; +/** + * Create a pipeline for the aggregation to get all prompt groups + * @param {Object} query + * @param {Partial} $project + * @returns {[Object]} - The pipeline for the aggregation + */ +const createAllGroupsPipeline = ( + query, + $project = { + name: 1, + oneliner: 1, + category: 1, + author: 1, + authorName: 1, + createdAt: 1, + updatedAt: 1, + command: 1, + 'productionPrompt.prompt': 1, + }, +) => { + return [ + { $match: query }, + { $sort: { createdAt: -1 } }, + { + $lookup: { + from: 'prompts', + localField: 'productionId', + foreignField: '_id', + as: 'productionPrompt', + }, + }, + { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } }, + { + $project, + }, + ]; +}; + +/** + * Get all prompt groups with filters + * @param {Object} req + * @param {TPromptGroupsWithFilterRequest} filter + * @returns {Promise} + */ +const getAllPromptGroups = async (req, filter) => { + try { + const { name, ...query } = filter; + + if (!query.author) { + throw new Error('Author is required'); + } + + let searchShared = true; + let searchSharedOnly = false; + if (name) { + query.name = new RegExp(name, 'i'); + } + if (!query.category) { + delete query.category; + } else if (query.category === SystemCategories.MY_PROMPTS) { + searchShared = false; + delete query.category; + } else if (query.category === SystemCategories.NO_CATEGORY) { + query.category = ''; + } else if (query.category === SystemCategories.SHARED_PROMPTS) { + searchSharedOnly = true; + delete query.category; + } + + let combinedQuery = query; + + if (searchShared) { + const project = await getProjectByName('instance', 'promptGroupIds'); + if (project && project.promptGroupIds.length > 0) { + const projectQuery = { _id: { $in: project.promptGroupIds }, ...query }; + delete projectQuery.author; + combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] }; + } + } + + const promptGroupsPipeline = createAllGroupsPipeline(combinedQuery); + return await PromptGroup.aggregate(promptGroupsPipeline).exec(); + } catch (error) { + console.error('Error getting all prompt groups', error); + return { message: 'Error getting all prompt groups' }; + } +}; + /** * Get prompt groups with filters * @param {Object} req @@ -126,6 +214,7 @@ const getPromptGroups = async (req, filter) => { module.exports = { getPromptGroups, + getAllPromptGroups, /** * Create a prompt and its respective group * @param {TCreatePromptRecord} saveData diff --git a/api/models/schema/promptSchema.js b/api/models/schema/promptSchema.js index 4aeb1deb28..5464caf639 100644 --- a/api/models/schema/promptSchema.js +++ b/api/models/schema/promptSchema.js @@ -1,4 +1,5 @@ const mongoose = require('mongoose'); +const { Constants } = require('librechat-data-provider'); const Schema = mongoose.Schema; /** @@ -12,6 +13,7 @@ const Schema = mongoose.Schema; * @property {number} [numberOfGenerations=0] - Number of generations the prompt group has * @property {string} [oneliner=''] - Oneliner description of the prompt group * @property {string} [category=''] - Category of the prompt group + * @property {string} [command] - Command for the prompt group * @property {Date} [createdAt] - Date when the prompt group was created (added by timestamps) * @property {Date} [updatedAt] - Date when the prompt group was last updated (added by timestamps) */ @@ -57,6 +59,21 @@ const promptGroupSchema = new Schema( type: String, required: true, }, + command: { + type: String, + index: true, + validate: { + validator: function (v) { + return v === undefined || v === null || v === '' || /^[a-z0-9-]+$/.test(v); + }, + message: (props) => + `${props.value} is not a valid command. Only lowercase alphanumeric characters and highfins (') are allowed.`, + }, + maxlength: [ + Constants.COMMANDS_MAX_LENGTH, + `Command cannot be longer than ${Constants.COMMANDS_MAX_LENGTH} characters`, + ], + }, }, { timestamps: true, diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 38a9e51ba1..5a6dcafcb6 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -10,6 +10,7 @@ const { updatePromptGroup, deletePromptGroup, createPromptGroup, + getAllPromptGroups, // updatePromptLabels, makePromptProduction, } = require('~/models/Prompt'); @@ -65,6 +66,22 @@ router.get('/groups/:groupId', async (req, res) => { } }); +/** + * Route to fetch all prompt groups + * GET /groups + */ +router.get('/all', async (req, res) => { + try { + const groups = await getAllPromptGroups(req, { + author: req.user._id, + }); + res.status(200).send(groups); + } catch (error) { + logger.error(error); + res.status(500).send({ error: 'Error getting prompt groups' }); + } +}); + /** * Route to fetch paginated prompt groups with filters * GET /groups diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 9efc42b5ce..729024b7e9 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -62,7 +62,9 @@ const sendVerificationEmail = async (user) => { let verifyToken = crypto.randomBytes(32).toString('hex'); const hash = bcrypt.hashSync(verifyToken, 10); - const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; + const verificationLink = `${ + domains.client + }/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; await sendEmail({ email: user.email, subject: 'Verify your email', @@ -119,9 +121,10 @@ const verifyEmail = async (req) => { /** * Register a new user. * @param {MongoUser} user + * @param {Partial} [additionalData={}] * @returns {Promise<{status: number, message: string, user?: MongoUser}>} */ -const registerUser = async (user) => { +const registerUser = async (user, additionalData = {}) => { const { error } = registerSchema.safeParse(user); if (error) { const errorMessage = errorsToString(error.errors); @@ -171,11 +174,13 @@ const registerUser = async (user) => { avatar: null, role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, password: bcrypt.hashSync(password, salt), + ...additionalData, }; const emailEnabled = checkEmailConfig(); - newUserId = await createUser(newUserData, false); - if (emailEnabled) { + const newUser = await createUser(newUserData, false, true); + newUserId = newUser._id; + if (emailEnabled && !newUser.emailVerified) { await sendVerificationEmail({ _id: newUserId, email, @@ -363,7 +368,9 @@ const resendVerificationEmail = async (req) => { let verifyToken = crypto.randomBytes(32).toString('hex'); const hash = bcrypt.hashSync(verifyToken, 10); - const verificationLink = `${domains.client}/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; + const verificationLink = `${ + domains.client + }/verify?token=${verifyToken}&email=${encodeURIComponent(user.email)}`; await sendEmail({ email: user.email, diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 8ae86a9c7e..492f63fd67 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -375,6 +375,9 @@ export type MentionOption = OptionWithIcon & { value: string; description?: string; }; +export type PromptOption = MentionOption & { + id: string; +}; export type TOptionSettings = { showExamples?: boolean; diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index e86221d5e1..63ac15b01b 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -23,6 +23,7 @@ import { TextareaAutosize } from '~/components/ui'; import { useGetFileConfig } from '~/data-provider'; import { cn, removeFocusRings } from '~/utils'; import TextareaHeader from './TextareaHeader'; +import PromptsCommand from './PromptsCommand'; import AttachFile from './Files/AttachFile'; import AudioRecorder from './AudioRecorder'; import { mainTextareaId } from '~/common'; @@ -48,7 +49,12 @@ const ChatForm = ({ index = 0 }) => { ); const { requiresKey } = useRequiresKey(); - const handleKeyUp = useHandleKeyUp({ textAreaRef, setShowPlusPopover, setShowMentionPopover }); + const handleKeyUp = useHandleKeyUp({ + index, + textAreaRef, + setShowPlusPopover, + setShowMentionPopover, + }); const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({ textAreaRef, submitButtonRef, @@ -83,7 +89,7 @@ const ChatForm = ({ index = 0 }) => { }); const assistantMap = useAssistantsMapContext(); - const { submitMessage } = useSubmitMessage({ clearDraft }); + const { submitMessage, submitPrompt } = useSubmitMessage({ clearDraft }); const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; const endpoint = endpointType ?? _endpoint; @@ -136,6 +142,7 @@ const ChatForm = ({ index = 0 }) => { textAreaRef={textAreaRef} /> )} +
void; + }) => { + const showPromptsPopover = useRecoilValue(store.showPromptsPopoverFamily(index)); + return ( + <> + {showPromptsPopover ? children : null} + setVariableDialogOpen(false)} + group={variableGroup} + /> + + ); + }, +); + +function PromptsCommand({ + index, + textAreaRef, + submitPrompt, +}: { + index: number; + textAreaRef: React.MutableRefObject; + submitPrompt: (textPrompt: string) => void; +}) { + const localize = useLocalize(); + + const { data, isLoading } = useGetAllPromptGroups(undefined, { + select: (data) => { + const mappedArray = data.map((group) => ({ + id: group._id, + value: group.command ?? group.name, + label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${ + group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? '' + }`, + icon: , + })); + + const promptsMap = mapPromptGroups(data); + + return { + promptsMap, + promptGroups: mappedArray, + }; + }, + }); + + const [activeIndex, setActiveIndex] = useState(0); + const timeoutRef = useRef(null); + const inputRef = useRef(null); + const [isVariableDialogOpen, setVariableDialogOpen] = useState(false); + const [variableGroup, setVariableGroup] = useState(null); + const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index)); + + const prompts = useMemo(() => data?.promptGroups ?? [], [data]); + const promptsMap = useMemo(() => data?.promptsMap ?? {}, [data]); + + const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({ + value: '', + options: prompts, + }); + + const handleSelect = useCallback( + (mention?: PromptOption, e?: React.KeyboardEvent) => { + if (!mention) { + return; + } + + setSearchValue(''); + setOpen(false); + setShowPromptsPopover(false); + + if (textAreaRef.current) { + removeCharIfLast(textAreaRef.current, commandChar); + } + + const isValidPrompt = mention && promptsMap && promptsMap[mention.id]; + + if (!isValidPrompt) { + return; + } + + const group = promptsMap[mention.id]; + const hasVariables = detectVariables(group?.productionPrompt?.prompt ?? ''); + if (group && hasVariables) { + if (e && e.key === 'Tab') { + e.preventDefault(); + } + setVariableGroup(group); + setVariableDialogOpen(true); + return; + } else if (group) { + submitPrompt(group.productionPrompt?.prompt ?? ''); + } + }, + [setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt], + ); + + useEffect(() => { + if (!open) { + setActiveIndex(0); + } else { + setVariableGroup(null); + } + }, [open]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + useEffect(() => { + const currentActiveItem = document.getElementById(`prompt-item-${activeIndex}`); + currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); + }, [activeIndex]); + + return ( + +
+
+ { + if (e.key === 'Escape') { + setOpen(false); + setShowPromptsPopover(false); + textAreaRef.current?.focus(); + } + if (e.key === 'ArrowDown') { + setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length); + } else if (e.key === 'ArrowUp') { + setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length); + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (e.key === 'Enter') { + e.preventDefault(); + } + handleSelect(matches[activeIndex] as PromptOption | undefined, e); + } else if (e.key === 'Backspace' && searchValue === '') { + setOpen(false); + setShowPromptsPopover(false); + textAreaRef.current?.focus(); + } + }} + onChange={(e) => setSearchValue(e.target.value)} + onFocus={() => setOpen(true)} + onBlur={() => { + timeoutRef.current = setTimeout(() => { + setOpen(false); + setShowPromptsPopover(false); + }, 150); + }} + /> +
+ {(() => { + if (isLoading && open) { + return ( +
+ +
+ ); + } + + if (!isLoading && open) { + return (matches as PromptOption[]).map((mention, index) => ( + { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = null; + handleSelect(mention); + }} + name={mention.label ?? ''} + icon={mention.icon} + description={mention.description} + isActive={index === activeIndex} + /> + )); + } + return null; + })()} +
+
+
+
+ ); +} + +export default memo(PromptsCommand); diff --git a/client/src/components/Prompts/Command.tsx b/client/src/components/Prompts/Command.tsx new file mode 100644 index 0000000000..40c2177734 --- /dev/null +++ b/client/src/components/Prompts/Command.tsx @@ -0,0 +1,66 @@ +import { SquareSlash } from 'lucide-react'; +import { Constants } from 'librechat-data-provider'; +import { useState, useEffect } from 'react'; +import { useLocalize } from '~/hooks'; + +const Command = ({ + initialValue, + onValueChange, + disabled, + tabIndex, +}: { + initialValue?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + tabIndex?: number; +}) => { + const localize = useLocalize(); + const [command, setCommand] = useState(initialValue || ''); + const [charCount, setCharCount] = useState(initialValue?.length || 0); + + useEffect(() => { + setCommand(initialValue || ''); + setCharCount(initialValue?.length || 0); + }, [initialValue]); + + useEffect(() => { + setCharCount(command.length); + }, [command]); + + const handleInputChange: React.ChangeEventHandler = (e) => { + let newValue = e.target.value.toLowerCase(); + + newValue = newValue.replace(/\s/g, '-').replace(/[^a-z0-9-]/g, ''); + + if (newValue.length <= Constants.COMMANDS_MAX_LENGTH) { + setCommand(newValue); + onValueChange?.(newValue); + } + }; + + if (disabled && !command) { + return null; + } + + return ( +
+

+ + + {!disabled && ( + {`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`} + )} +

+
+ ); +}; + +export default Command; diff --git a/client/src/components/Prompts/Groups/CreatePromptForm.tsx b/client/src/components/Prompts/Groups/CreatePromptForm.tsx index 215948d880..f8c301f656 100644 --- a/client/src/components/Prompts/Groups/CreatePromptForm.tsx +++ b/client/src/components/Prompts/Groups/CreatePromptForm.tsx @@ -6,8 +6,9 @@ import CategorySelector from '~/components/Prompts/Groups/CategorySelector'; import PromptVariables from '~/components/Prompts/PromptVariables'; import { Button, TextareaAutosize, Input } from '~/components/ui'; import Description from '~/components/Prompts/Description'; -import { useCreatePrompt } from '~/data-provider'; import { useLocalize, useHasAccess } from '~/hooks'; +import Command from '~/components/Prompts/Command'; +import { useCreatePrompt } from '~/data-provider'; import { cn } from '~/utils'; type CreateFormValues = { @@ -16,6 +17,7 @@ type CreateFormValues = { type: 'text' | 'chat'; category: string; oneliner?: string; + command?: string; }; const defaultPrompt: CreateFormValues = { @@ -24,6 +26,7 @@ const defaultPrompt: CreateFormValues = { type: 'text', category: '', oneliner: undefined, + command: undefined, }; const CreatePromptForm = ({ @@ -73,14 +76,17 @@ const CreatePromptForm = ({ const promptText = watch('prompt'); const onSubmit = (data: CreateFormValues) => { - const { name, category, oneliner, ...rest } = data; + const { name, category, oneliner, command, ...rest } = data; const groupData = { name, category } as Pick< CreateFormValues, - 'name' | 'category' | 'oneliner' + 'name' | 'category' | 'oneliner' | 'command' >; if ((oneliner?.length || 0) > 0) { groupData.oneliner = oneliner; } + if ((command?.length || 0) > 0) { + groupData.command = command; + } createPromptMutation.mutate({ prompt: rest, group: groupData, @@ -121,15 +127,15 @@ const CreatePromptForm = ({
)} /> - + -
+

{localize('com_ui_prompt_text')}*

-
+
methods.setValue('oneliner', value)} tabIndex={3} /> + methods.setValue('command', value)} tabIndex={4} />
-
+

{localize('com_ui_prompt_text')}

-
+
{promptText}
+
diff --git a/client/src/components/Prompts/PromptEditor.tsx b/client/src/components/Prompts/PromptEditor.tsx index 933ca29bb4..034efb7c97 100644 --- a/client/src/components/Prompts/PromptEditor.tsx +++ b/client/src/components/Prompts/PromptEditor.tsx @@ -50,7 +50,7 @@ const PromptEditor: React.FC = ({ name, isEditing, setIsEditing }) => {
!isEditing && setIsEditing(true)} diff --git a/client/src/components/Prompts/PromptForm.tsx b/client/src/components/Prompts/PromptForm.tsx index 4530e11121..12fde151eb 100644 --- a/client/src/components/Prompts/PromptForm.tsx +++ b/client/src/components/Prompts/PromptForm.tsx @@ -14,12 +14,13 @@ import { useUpdatePromptGroup, useMakePromptProduction, } from '~/data-provider'; -import { useAuthContext, usePromptGroupsNav, useHasAccess } from '~/hooks'; +import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks'; import CategorySelector from './Groups/CategorySelector'; import AlwaysMakeProd from './Groups/AlwaysMakeProd'; import NoPromptGroup from './Groups/NoPromptGroup'; import { Button, Skeleton } from '~/components/ui'; import PromptVariables from './PromptVariables'; +import { useToastContext } from '~/Providers'; import PromptVersions from './PromptVersions'; import DeleteConfirm from './DeleteVersion'; import PromptDetails from './PromptDetails'; @@ -29,6 +30,7 @@ import SkeletonForm from './SkeletonForm'; import Description from './Description'; import SharePrompt from './SharePrompt'; import PromptName from './PromptName'; +import Command from './Command'; import store from '~/store'; const { PromptsEditorMode, promptsEditorMode } = store; @@ -36,7 +38,10 @@ const { PromptsEditorMode, promptsEditorMode } = store; const PromptForm = () => { const params = useParams(); const navigate = useNavigate(); + const localize = useLocalize(); + const { user } = useAuthContext(); + const { showToast } = useToastContext(); const editorMode = useRecoilValue(promptsEditorMode); const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd); const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(params.promptId || ''); @@ -101,7 +106,14 @@ const PromptForm = () => { setSelectionIndex(0); }, }); - const updateGroupMutation = useUpdatePromptGroup(); + const updateGroupMutation = useUpdatePromptGroup({ + onError: () => { + showToast({ + status: 'error', + message: localize('com_ui_prompt_update_error'), + }); + }, + }); const makeProductionMutation = useMakePromptProduction(); const deletePromptMutation = useDeletePrompt({ onSuccess: (response) => { @@ -175,6 +187,17 @@ const PromptForm = () => { [updateGroupMutation, group], ); + const debouncedUpdateCommand = useCallback( + debounce((command: string) => { + if (!group) { + return console.warn('Group not found'); + } + + updateGroupMutation.mutate({ id: group._id || '', payload: { command } }); + }, 950), + [updateGroupMutation, group], + ); + const { groupsQuery } = useOutletContext>(); if (initialLoad) { @@ -282,14 +305,18 @@ const PromptForm = () => { {isLoadingPrompts ? ( ) : ( - <> +
- + +
)}
{/* Right Section */} diff --git a/client/src/components/Prompts/PromptVariables.tsx b/client/src/components/Prompts/PromptVariables.tsx index e3e30eb3a9..7b480b1c67 100644 --- a/client/src/components/Prompts/PromptVariables.tsx +++ b/client/src/components/Prompts/PromptVariables.tsx @@ -20,12 +20,12 @@ const PromptVariables = ({ promptText }: { promptText: string }) => { }, [promptText]); return ( - <> +

{localize('com_ui_variables')}

-
+
{variables.length ? (
{variables.map((variable, index) => ( @@ -52,7 +52,7 @@ const PromptVariables = ({ promptText }: { promptText: string }) => { {localize('com_ui_special_variables')}
- +
); }; diff --git a/client/src/data-provider/prompts.ts b/client/src/data-provider/prompts.ts index ab54b94051..1fcb8ea64b 100644 --- a/client/src/data-provider/prompts.ts +++ b/client/src/data-provider/prompts.ts @@ -5,9 +5,12 @@ import type { UseMutationResult } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; import { /* Prompts */ + addGroupToAll, addPromptGroup, + updateGroupInAll, updateGroupFields, deletePromptGroup, + removeGroupFromAll, } from '~/utils'; import store from '~/store'; @@ -83,7 +86,12 @@ export const useUpdatePromptGroup = ( onError(err, variables, context); } }, - onSuccess, + onSuccess: (response, variables, context) => { + updateGroupInAll(queryClient, { _id: variables.id, ...response }); + if (onSuccess) { + onSuccess(response, variables, context); + } + }, }); }; @@ -118,6 +126,8 @@ export const useCreatePrompt = ( return addPromptGroup(data, group); }, ); + + addGroupToAll(queryClient, group); } if (onSuccess) { @@ -151,6 +161,8 @@ export const useDeletePrompt = ( return deletePromptGroup(data, promptGroupId); }, ); + + removeGroupFromAll(queryClient, promptGroupId); } else { queryClient.setQueryData( [QueryKeys.prompts, variables.groupId], @@ -208,6 +220,8 @@ export const useDeletePromptGroup = ( return deletePromptGroup(data, variables.id); }, ); + + removeGroupFromAll(queryClient, variables.id); if (onSuccess) { onSuccess(response, variables, context); } @@ -299,6 +313,15 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions) onError(err, variables, context); } }, - onSuccess, + onSuccess: (response, variables, context) => { + updateGroupInAll(queryClient, { + _id: variables.groupId, + productionId: variables.id, + productionPrompt: variables.productionPrompt, + }); + if (onSuccess) { + onSuccess(response, variables, context); + } + }, }); }; diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index e346418540..666a19e6cc 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -488,6 +488,23 @@ export const useGetPrompts = ( ); }; +export const useGetAllPromptGroups = ( + filter?: t.AllPromptGroupsFilterRequest, + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.allPromptGroups], + () => dataService.getAllPromptGroups(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + ...config, + }, + ); +}; + export const useGetCategories = ( config?: UseQueryOptions, ): QueryObserverResult => { diff --git a/client/src/hooks/Input/useHandleKeyUp.ts b/client/src/hooks/Input/useHandleKeyUp.ts index 726924edd5..e4d0c58d52 100644 --- a/client/src/hooks/Input/useHandleKeyUp.ts +++ b/client/src/hooks/Input/useHandleKeyUp.ts @@ -1,5 +1,9 @@ +import { useSetRecoilState } from 'recoil'; import { useCallback, useMemo } from 'react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { SetterOrUpdater } from 'recoil'; +import useHasAccess from '~/hooks/Roles/useHasAccess'; +import store from '~/store'; /** Event Keys that shouldn't trigger a command */ const invalidKeys = { @@ -36,14 +40,21 @@ const shouldTriggerCommand = ( * Custom hook for handling key up events with command triggers. */ const useHandleKeyUp = ({ + index, textAreaRef, setShowPlusPopover, setShowMentionPopover, }: { + index: number; textAreaRef: React.RefObject; setShowPlusPopover: SetterOrUpdater; setShowMentionPopover: SetterOrUpdater; }) => { + const hasAccess = useHasAccess({ + permissionType: PermissionTypes.PROMPTS, + permission: Permissions.USE, + }); + const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index)); const handleAtCommand = useCallback(() => { if (shouldTriggerCommand(textAreaRef, '@')) { setShowMentionPopover(true); @@ -56,12 +67,22 @@ const useHandleKeyUp = ({ } }, [textAreaRef, setShowPlusPopover]); + const handlePromptsCommand = useCallback(() => { + if (!hasAccess) { + return; + } + if (shouldTriggerCommand(textAreaRef, '/')) { + setShowPromptsPopover(true); + } + }, [textAreaRef, hasAccess, setShowPromptsPopover]); + const commandHandlers = useMemo( () => ({ '@': handleAtCommand, '+': handlePlusCommand, + '/': handlePromptsCommand, }), - [handleAtCommand, handlePlusCommand], + [handleAtCommand, handlePlusCommand, handlePromptsCommand], ); /** diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 326396b5b1..2095bf1504 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -32,22 +32,13 @@ export default function useSideNavLinks({ endpoint?: EModelEndpoint | null; interfaceConfig: Partial; }) { - const hasAccess = useHasAccess({ + const hasAccessToPrompts = useHasAccess({ permissionType: PermissionTypes.PROMPTS, permission: Permissions.USE, }); const Links = useMemo(() => { const links: NavLink[] = []; - if (hasAccess) { - links.push({ - title: 'com_ui_prompts', - label: '', - icon: MessageSquareQuote, - id: 'prompts', - Component: PromptsAccordion, - }); - } if ( isAssistantsEndpoint(endpoint) && assistants && @@ -64,6 +55,16 @@ export default function useSideNavLinks({ }); } + if (hasAccessToPrompts) { + links.push({ + title: 'com_ui_prompts', + label: '', + icon: MessageSquareQuote, + id: 'prompts', + Component: PromptsAccordion, + }); + } + links.push({ title: 'com_sidepanel_attach_files', label: '', @@ -81,7 +82,14 @@ export default function useSideNavLinks({ }); return links; - }, [assistants, keyProvided, hidePanel, endpoint, interfaceConfig.parameters, hasAccess]); + }, [ + assistants, + keyProvided, + hidePanel, + endpoint, + interfaceConfig.parameters, + hasAccessToPrompts, + ]); return Links; } diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 64774ee73a..852df14ab5 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -7,8 +7,7 @@ export default { 'It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We\'re unable to proceed with this specific topic. If you have any other questions or topics you\'d like to explore, please edit your message, or create a new conversation.', com_error_no_user_key: 'No key found. Please provide a key and try again.', com_error_no_base_url: 'No base URL found. Please provide one and try again.', - com_error_invalid_user_key: - 'Invalid key provided. Please provide a valid key and try again.', + com_error_invalid_user_key: 'Invalid key provided. Please provide a valid key and try again.', com_error_expired_user_key: 'Provided key for {0} expired at {1}. Please provide a new key and try again.', com_files_no_results: 'No results.', @@ -247,8 +246,11 @@ export default { com_ui_prompts_allow_create: 'Allow creating Prompts', com_ui_prompts_allow_share_global: 'Allow sharing Prompts to all users', com_ui_prompt_shared_to_all: 'This prompt is shared to all users', + com_ui_prompt_update_error: 'There was an error updating the prompt', com_ui_prompt_already_shared_to_all: 'This prompt is already shared to all users', - com_ui_description_placeholder: 'Optional: Enter a description to display in the prompt', + com_ui_description_placeholder: 'Optional: Enter a description to display for the prompt', + com_ui_command_placeholder: 'Optional: Enter a command for the prompt or name will be used.', + com_ui_command_usage_placeholder: 'Select a Prompt by command or name', com_ui_no_prompt_description: 'No description found.', com_ui_share_link_to_chat: 'Share link to chat', com_ui_share_error: 'There was an error sharing the chat link', @@ -641,7 +643,8 @@ export default { 'When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.', com_nav_info_fork_change_default: '`Visible messages only` includes just the direct path to the selected message. `Include related branches` adds branches along the path. `Include all to/from here` includes all connected messages and branches.', - com_nav_info_fork_split_target_setting: 'When enabled, forking will commence from the target message to the latest message in the conversation, according to the behavior selected.', + com_nav_info_fork_split_target_setting: + 'When enabled, forking will commence from the target message to the latest message in the conversation, according to the behavior selected.', com_nav_info_user_name_display: 'When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see "You" above your messages.', com_nav_info_latex_parsing: diff --git a/client/src/routes/Layouts/DashBreadcrumb.tsx b/client/src/routes/Layouts/DashBreadcrumb.tsx index 1ccc32d22a..a0456c933d 100644 --- a/client/src/routes/Layouts/DashBreadcrumb.tsx +++ b/client/src/routes/Layouts/DashBreadcrumb.tsx @@ -21,7 +21,7 @@ import AdminSettings from '~/components/Prompts/AdminSettings'; import { useDashboardContext } from '~/Providers'; import store from '~/store'; -const promptsPathPattern = /prompts\/.*/; +const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/; const getConversationId = (prevLocationPath: string) => { if (!prevLocationPath || prevLocationPath.includes('/d/')) { diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 5080dc95ae..13e94dabb7 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -173,6 +173,11 @@ const showPlusPopoverFamily = atomFamily({ default: false, }); +const showPromptsPopoverFamily = atomFamily({ + key: 'showPromptsPopoverByIndex', + default: false, +}); + const globalAudioURLFamily = atomFamily({ key: 'globalAudioURLByIndex', default: null, @@ -326,4 +331,5 @@ export default { activePromptByIndex, useClearSubmissionState, useClearLatestMessages, + showPromptsPopoverFamily, }; diff --git a/client/src/style.css b/client/src/style.css index 6fb425958f..70b2ce047b 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -32,6 +32,7 @@ html { --surface-primary-contrast:var(--gray-100); --surface-secondary:var(--gray-50); --surface-tertiary:var(--gray-100); + --surface-tertiary-alt:var(--white); --border-light:var(--gray-100); --border-medium-alt:var(--gray-300); --border-medium:var(--gray-200); @@ -48,6 +49,7 @@ html { --surface-primary-contrast:var(--gray-850); --surface-secondary:var(--gray-800); --surface-tertiary:var(--gray-700); + --surface-tertiary-alt:var(--gray-700); --border-light:var(--gray-700); --border-medium-alt:var(--gray-600); --border-medium:var(--gray-600); diff --git a/client/src/utils/collection.ts b/client/src/utils/collection.ts index 36972d2f71..a46d45b4d6 100644 --- a/client/src/utils/collection.ts +++ b/client/src/utils/collection.ts @@ -1,4 +1,4 @@ -import { InfiniteData } from '@tanstack/react-query'; +import { InfiniteData, QueryClient } from '@tanstack/react-query'; export const addData = ( data: InfiniteData, @@ -165,3 +165,56 @@ export const updateFields = ( return newData; }; + +type UpdateCacheListOptions = { + queryClient: QueryClient; + queryKey: unknown[]; + searchProperty: keyof TData; + updateData: Partial; + searchValue: unknown; +}; + +export function updateCacheList({ + queryClient, + queryKey, + searchProperty, + updateData, + searchValue, +}: UpdateCacheListOptions) { + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) { + return oldData; + } + + return oldData.map((item) => + item[searchProperty] === searchValue ? { ...item, ...updateData } : item, + ); + }); +} + +export function addToCacheList( + queryClient: QueryClient, + queryKey: unknown[], + newItem: TData, +) { + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) { + return [newItem]; + } + return [...oldData, newItem]; + }); +} + +export function removeFromCacheList( + queryClient: QueryClient, + queryKey: unknown[], + searchProperty: keyof TData, + searchValue: unknown, +) { + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) { + return oldData; + } + return oldData.filter((item) => item[searchProperty] !== searchValue); + }); +} diff --git a/client/src/utils/promptGroups.ts b/client/src/utils/promptGroups.ts index 499735b145..b33ca0ac4c 100644 --- a/client/src/utils/promptGroups.ts +++ b/client/src/utils/promptGroups.ts @@ -1,11 +1,20 @@ -import { InfiniteCollections } from 'librechat-data-provider'; +import { InfiniteCollections, QueryKeys } from 'librechat-data-provider'; +import type { InfiniteData, QueryClient } from '@tanstack/react-query'; import type { PromptGroupListResponse, PromptGroupListData, TPromptGroup, } from 'librechat-data-provider'; -import { addData, deleteData, updateData, updateFields, getRecordByProperty } from './collection'; -import { InfiniteData } from '@tanstack/react-query'; +import { + addData, + deleteData, + updateData, + updateFields, + addToCacheList, + updateCacheList, + removeFromCacheList, + getRecordByProperty, +} from './collection'; export const addPromptGroup = ( data: InfiniteData, @@ -70,3 +79,24 @@ export const findPromptGroup = ( findProperty, ); }; + +export const addGroupToAll = (queryClient: QueryClient, newGroup: TPromptGroup) => { + addToCacheList(queryClient, [QueryKeys.allPromptGroups], newGroup); +}; + +export const updateGroupInAll = ( + queryClient: QueryClient, + updatedGroup: Partial & { _id: string }, +) => { + updateCacheList({ + queryClient, + queryKey: [QueryKeys.allPromptGroups], + searchProperty: '_id', + updateData: updatedGroup, + searchValue: updatedGroup._id, + }); +}; + +export const removeGroupFromAll = (queryClient: QueryClient, groupId: string) => { + removeFromCacheList(queryClient, [QueryKeys.allPromptGroups], '_id', groupId); +}; diff --git a/client/src/utils/prompts.ts b/client/src/utils/prompts.ts index e4267f27e6..cace31dd24 100644 --- a/client/src/utils/prompts.ts +++ b/client/src/utils/prompts.ts @@ -1,5 +1,5 @@ import { format } from 'date-fns'; -import type { TUser } from 'librechat-data-provider'; +import type { TUser, TPromptGroup } from 'librechat-data-provider'; export function replaceSpecialVars({ text, user }: { text: string; user?: TUser }) { if (!text) { @@ -92,3 +92,13 @@ export function formatDateTime(dateTimeString: string) { return `${formattedDate}, ${formattedTime}`; } + +export const mapPromptGroups = (groups: TPromptGroup[]): Record => { + return groups.reduce((acc, group) => { + if (!group._id) { + return acc; + } + acc[group._id] = group; + return acc; + }, {} as Record); +}; diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index c22c57191f..b54c2a7d7c 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -73,6 +73,7 @@ module.exports = { 'surface-primary-contrast': 'var(--surface-primary-contrast)', 'surface-secondary': 'var(--surface-secondary)', 'surface-tertiary': 'var(--surface-tertiary)', + 'surface-tertiary-alt': 'var(--surface-tertiary-alt)', 'border-light': 'var(--border-light)', 'border-medium': 'var(--border-medium)', 'border-medium-alt': 'var(--border-medium-alt)', diff --git a/config/create-user.js b/config/create-user.js index 1dfa93e55c..8c5429b538 100644 --- a/config/create-user.js +++ b/config/create-user.js @@ -8,50 +8,48 @@ const connect = require('./connect'); (async () => { await connect(); - /** - * Show the welcome / help menu - */ console.purple('--------------------------'); console.purple('Create a new user account!'); console.purple('--------------------------'); - // If we don't have enough arguments, show the help menu + if (process.argv.length < 5) { - console.orange('Usage: npm run create-user '); + console.orange('Usage: npm run create-user [--email-verified=false]'); console.orange('Note: if you do not pass in the arguments, you will be prompted for them.'); console.orange( 'If you really need to pass in the password, you can do so as the 4th argument (not recommended for security).', ); + console.orange('Use --email-verified=false to set emailVerified to false. Default is true.'); console.purple('--------------------------'); } - /** - * Set up the variables we need and get the arguments if they were passed in - */ let email = ''; let password = ''; let name = ''; let username = ''; - // If we have the right number of arguments, lets use them - if (process.argv.length >= 4) { - email = process.argv[2]; - name = process.argv[3]; + let emailVerified = true; - if (process.argv.length >= 5) { - username = process.argv[4]; + // Parse command line arguments + for (let i = 2; i < process.argv.length; i++) { + if (process.argv[i].startsWith('--email-verified=')) { + emailVerified = process.argv[i].split('=')[1].toLowerCase() !== 'false'; + continue; } - if (process.argv.length >= 6) { + + if (!email) { + email = process.argv[i]; + } else if (!name) { + name = process.argv[i]; + } else if (!username) { + username = process.argv[i]; + } else if (!password) { console.red('Warning: password passed in as argument, this is not secure!'); - password = process.argv[5]; + password = process.argv[i]; } } - /** - * If we don't have the right number of arguments, lets prompt the user for them - */ if (!email) { email = await askQuestion('Email:'); } - // Validate the email if (!email.includes('@')) { console.red('Error: Invalid email address!'); silentExit(1); @@ -73,41 +71,51 @@ const connect = require('./connect'); if (!password) { password = await askQuestion('Password: (leave blank, to generate one)'); if (!password) { - // Make it a random password, length 18 password = Math.random().toString(36).slice(-18); console.orange('Your password is: ' + password); } } - // Validate the user doesn't already exist + // Only prompt for emailVerified if it wasn't set via CLI + if (!process.argv.some((arg) => arg.startsWith('--email-verified='))) { + const emailVerifiedInput = await askQuestion(`Email verified? (Y/n, default is Y): + +If \`y\`, the user's email will be considered verified. + +If \`n\`, and email service is configured, the user will be sent a verification email. + +If \`n\`, and email service is not configured, you must have the \`ALLOW_UNVERIFIED_EMAIL_LOGIN\` .env variable set to true, +or the user will need to attempt logging in to have a verification link sent to them.`); + + if (emailVerifiedInput.toLowerCase() === 'n') { + emailVerified = false; + } + } + const userExists = await User.findOne({ $or: [{ email }, { username }] }); if (userExists) { console.red('Error: A user with that email or username already exists!'); silentExit(1); } - /** - * Now that we have all the variables we need, lets create the user - */ const user = { email, password, name, username, confirm_password: password }; let result; try { - result = await registerUser(user); + result = await registerUser(user, { emailVerified }); } catch (error) { console.red('Error: ' + error.message); silentExit(1); } - // Check the result if (result.status !== 200) { console.red('Error: ' + result.message); silentExit(1); } - // Done! const userCreated = await User.findOne({ $or: [{ email }, { username }] }); if (userCreated) { console.green('User created successfully!'); + console.green(`Email verified: ${userCreated.emailVerified}`); silentExit(0); } })(); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index c4b9a9aa66..0ec00b6384 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -177,6 +177,8 @@ export const deletePrompt = ({ _id, groupId }: { _id: string; groupId: string }) export const getCategories = () => '/api/categories'; +export const getAllPromptGroups = () => `${prompts()}/all`; + /* Roles */ export const roles = () => '/api/roles'; export const getRole = (roleName: string) => `${roles()}/${roleName.toLowerCase()}`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 0735bd6a5e..39f34ea368 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -822,6 +822,8 @@ export enum Constants { CURRENT_MODEL = 'current_model', /** Common divider for text values */ COMMON_DIVIDER = '__', + /** Max length for commands */ + COMMANDS_MAX_LENGTH = 56, } export enum LocalStorageKeys { diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 89c1d6356b..8250dcee17 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -475,6 +475,10 @@ export function getPrompts(filter: t.TPromptsWithFilterRequest): Promise { + return request.get(endpoints.getAllPromptGroups()); +} + export function getPromptGroups( filter: t.TPromptGroupsWithFilterRequest, ): Promise { diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index ec72f3d87e..c8692c1e06 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -30,6 +30,7 @@ export enum QueryKeys { prompts = 'prompts', prompt = 'prompt', promptGroups = 'promptGroups', + allPromptGroups = 'allPromptGroups', promptGroup = 'promptGroup', categories = 'categories', randomPrompts = 'randomPrompts', diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 04d6ff0e05..cfa9dfd55a 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -351,6 +351,7 @@ export type TPrompt = { export type TPromptGroup = { name: string; numberOfGenerations?: number; + command?: string; oneliner?: string; category?: string; projectIds?: string[]; @@ -365,7 +366,7 @@ export type TPromptGroup = { export type TCreatePrompt = { prompt: Pick & { groupId?: string }; - group?: { name: string; category?: string; oneliner?: string }; + group?: { name: string; category?: string; oneliner?: string; command?: string }; }; export type TCreatePromptRecord = TCreatePrompt & Pick; @@ -385,6 +386,7 @@ export type TPromptGroupsWithFilterRequest = { after?: string | null; order?: 'asc' | 'desc'; name?: string; + author?: string; }; export type PromptGroupListResponse = { diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index ad4c4db89e..010298d0bb 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -1,5 +1,6 @@ import type { InfiniteData } from '@tanstack/react-query'; import type { TMessage, TConversation, TSharedLink } from '../schemas'; +import type * as t from '../types'; export type Conversation = { id: string; createdAt: number; @@ -54,3 +55,16 @@ export type SharedLinkListResponse = { }; export type SharedLinkListData = InfiniteData; + +export type AllPromptGroupsFilterRequest = { + category: string; + pageNumber: string; + pageSize: string | number; + before?: string | null; + after?: string | null; + order?: 'asc' | 'desc'; + name?: string; + author?: string; +}; + +export type AllPromptGroupsResponse = t.TPromptGroup[];