🗨️ feat: Prompt Slash Commands (#3219)

* chore: Update prompt description placeholder text

* fix: promptsPathPattern to not include new

* feat: command input and styling change for prompt views

* fix: intended validation

* feat: prompts slash command

* chore: localizations and fix add command during creation

* refactor(PromptsCommand): better label

* feat: update `allPrompGroups` cache on all promptGroups mutations

* refactor: ensure assistants builder is first within sidepanel

* refactor: allow defining emailVerified via create-user script
This commit is contained in:
Danny Avila 2024-06-27 17:34:48 -04:00 committed by GitHub
parent b8f2bee3fc
commit 83619de158
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 764 additions and 80 deletions

View file

@ -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<MongoPromptGroup>} $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<PromptGroupListResponse>}
*/
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 * Get prompt groups with filters
* @param {Object} req * @param {Object} req
@ -126,6 +214,7 @@ const getPromptGroups = async (req, filter) => {
module.exports = { module.exports = {
getPromptGroups, getPromptGroups,
getAllPromptGroups,
/** /**
* Create a prompt and its respective group * Create a prompt and its respective group
* @param {TCreatePromptRecord} saveData * @param {TCreatePromptRecord} saveData

View file

@ -1,4 +1,5 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { Constants } = require('librechat-data-provider');
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
/** /**
@ -12,6 +13,7 @@ const Schema = mongoose.Schema;
* @property {number} [numberOfGenerations=0] - Number of generations the prompt group has * @property {number} [numberOfGenerations=0] - Number of generations the prompt group has
* @property {string} [oneliner=''] - Oneliner description of the prompt group * @property {string} [oneliner=''] - Oneliner description of the prompt group
* @property {string} [category=''] - Category 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} [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) * @property {Date} [updatedAt] - Date when the prompt group was last updated (added by timestamps)
*/ */
@ -57,6 +59,21 @@ const promptGroupSchema = new Schema(
type: String, type: String,
required: true, 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, timestamps: true,

View file

@ -10,6 +10,7 @@ const {
updatePromptGroup, updatePromptGroup,
deletePromptGroup, deletePromptGroup,
createPromptGroup, createPromptGroup,
getAllPromptGroups,
// updatePromptLabels, // updatePromptLabels,
makePromptProduction, makePromptProduction,
} = require('~/models/Prompt'); } = 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 * Route to fetch paginated prompt groups with filters
* GET /groups * GET /groups

View file

@ -62,7 +62,9 @@ const sendVerificationEmail = async (user) => {
let verifyToken = crypto.randomBytes(32).toString('hex'); let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10); 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({ await sendEmail({
email: user.email, email: user.email,
subject: 'Verify your email', subject: 'Verify your email',
@ -119,9 +121,10 @@ const verifyEmail = async (req) => {
/** /**
* Register a new user. * Register a new user.
* @param {MongoUser} user <email, password, name, username> * @param {MongoUser} user <email, password, name, username>
* @param {Partial<MongoUser>} [additionalData={}]
* @returns {Promise<{status: number, message: string, user?: MongoUser}>} * @returns {Promise<{status: number, message: string, user?: MongoUser}>}
*/ */
const registerUser = async (user) => { const registerUser = async (user, additionalData = {}) => {
const { error } = registerSchema.safeParse(user); const { error } = registerSchema.safeParse(user);
if (error) { if (error) {
const errorMessage = errorsToString(error.errors); const errorMessage = errorsToString(error.errors);
@ -171,11 +174,13 @@ const registerUser = async (user) => {
avatar: null, avatar: null,
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
password: bcrypt.hashSync(password, salt), password: bcrypt.hashSync(password, salt),
...additionalData,
}; };
const emailEnabled = checkEmailConfig(); const emailEnabled = checkEmailConfig();
newUserId = await createUser(newUserData, false); const newUser = await createUser(newUserData, false, true);
if (emailEnabled) { newUserId = newUser._id;
if (emailEnabled && !newUser.emailVerified) {
await sendVerificationEmail({ await sendVerificationEmail({
_id: newUserId, _id: newUserId,
email, email,
@ -363,7 +368,9 @@ const resendVerificationEmail = async (req) => {
let verifyToken = crypto.randomBytes(32).toString('hex'); let verifyToken = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(verifyToken, 10); 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({ await sendEmail({
email: user.email, email: user.email,

View file

@ -375,6 +375,9 @@ export type MentionOption = OptionWithIcon & {
value: string; value: string;
description?: string; description?: string;
}; };
export type PromptOption = MentionOption & {
id: string;
};
export type TOptionSettings = { export type TOptionSettings = {
showExamples?: boolean; showExamples?: boolean;

View file

@ -23,6 +23,7 @@ import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils'; import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader'; import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AttachFile from './Files/AttachFile'; import AttachFile from './Files/AttachFile';
import AudioRecorder from './AudioRecorder'; import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common'; import { mainTextareaId } from '~/common';
@ -48,7 +49,12 @@ const ChatForm = ({ index = 0 }) => {
); );
const { requiresKey } = useRequiresKey(); const { requiresKey } = useRequiresKey();
const handleKeyUp = useHandleKeyUp({ textAreaRef, setShowPlusPopover, setShowMentionPopover }); const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
});
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({ const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef, textAreaRef,
submitButtonRef, submitButtonRef,
@ -83,7 +89,7 @@ const ChatForm = ({ index = 0 }) => {
}); });
const assistantMap = useAssistantsMapContext(); const assistantMap = useAssistantsMapContext();
const { submitMessage } = useSubmitMessage({ clearDraft }); const { submitMessage, submitPrompt } = useSubmitMessage({ clearDraft });
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint; const endpoint = endpointType ?? _endpoint;
@ -136,6 +142,7 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef={textAreaRef} textAreaRef={textAreaRef}
/> />
)} )}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500"> <div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} /> <TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileRow <FileRow

View file

@ -0,0 +1,231 @@
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useGetAllPromptGroups } from '~/data-provider';
import { useLocalize, useCombobox } from '~/hooks';
import { Spinner } from '~/components/svg';
import MentionItem from './MentionItem';
import store from '~/store';
const commandChar = '/';
const PopoverContainer = memo(
({
index,
children,
isVariableDialogOpen,
variableGroup,
setVariableDialogOpen,
}: {
index: number;
children: React.ReactNode;
isVariableDialogOpen: boolean;
variableGroup: TPromptGroup | null;
setVariableDialogOpen: (isOpen: boolean) => void;
}) => {
const showPromptsPopover = useRecoilValue(store.showPromptsPopoverFamily(index));
return (
<>
{showPromptsPopover ? children : null}
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={variableGroup}
/>
</>
);
},
);
function PromptsCommand({
index,
textAreaRef,
submitPrompt,
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
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: <CategoryIcon category={group.category ?? ''} />,
}));
const promptsMap = mapPromptGroups(data);
return {
promptsMap,
promptGroups: mappedArray,
};
},
});
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(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<HTMLInputElement>) => {
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 (
<PopoverContainer
index={index}
isVariableDialogOpen={isVariableDialogOpen}
variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen}
>
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}
className="mb-1 w-full border-0 bg-surface-tertiary-alt p-2 text-sm focus:outline-none dark:text-gray-200"
autoComplete="off"
value={searchValue}
onKeyDown={(e) => {
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);
}}
/>
<div className="max-h-40 overflow-y-auto">
{(() => {
if (isLoading && open) {
return (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
);
}
if (!isLoading && open) {
return (matches as PromptOption[]).map((mention, index) => (
<MentionItem
index={index}
key={`${mention.value}-${index}`}
onClick={() => {
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;
})()}
</div>
</div>
</div>
</PopoverContainer>
);
}
export default memo(PromptsCommand);

View file

@ -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<HTMLInputElement> = (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 (
<div className="rounded-lg border border-border-medium">
<h3 className="flex h-10 items-center gap-2 pl-4 text-sm text-text-secondary">
<SquareSlash className="icon-sm" />
<input
type="text"
tabIndex={tabIndex}
disabled={disabled}
placeholder={localize('com_ui_command_placeholder')}
value={command}
onChange={handleInputChange}
className="w-full rounded-lg border-none bg-surface-tertiary p-1 text-text-primary placeholder:text-text-secondary-alt focus:bg-surface-tertiary focus:outline-none focus:ring-0 md:w-96"
/>
{!disabled && (
<span className="mr-1 w-10 text-xs text-text-tertiary md:text-sm">{`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`}</span>
)}
</h3>
</div>
);
};
export default Command;

View file

@ -6,8 +6,9 @@ import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
import PromptVariables from '~/components/Prompts/PromptVariables'; import PromptVariables from '~/components/Prompts/PromptVariables';
import { Button, TextareaAutosize, Input } from '~/components/ui'; import { Button, TextareaAutosize, Input } from '~/components/ui';
import Description from '~/components/Prompts/Description'; import Description from '~/components/Prompts/Description';
import { useCreatePrompt } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks'; import { useLocalize, useHasAccess } from '~/hooks';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils'; import { cn } from '~/utils';
type CreateFormValues = { type CreateFormValues = {
@ -16,6 +17,7 @@ type CreateFormValues = {
type: 'text' | 'chat'; type: 'text' | 'chat';
category: string; category: string;
oneliner?: string; oneliner?: string;
command?: string;
}; };
const defaultPrompt: CreateFormValues = { const defaultPrompt: CreateFormValues = {
@ -24,6 +26,7 @@ const defaultPrompt: CreateFormValues = {
type: 'text', type: 'text',
category: '', category: '',
oneliner: undefined, oneliner: undefined,
command: undefined,
}; };
const CreatePromptForm = ({ const CreatePromptForm = ({
@ -73,14 +76,17 @@ const CreatePromptForm = ({
const promptText = watch('prompt'); const promptText = watch('prompt');
const onSubmit = (data: CreateFormValues) => { const onSubmit = (data: CreateFormValues) => {
const { name, category, oneliner, ...rest } = data; const { name, category, oneliner, command, ...rest } = data;
const groupData = { name, category } as Pick< const groupData = { name, category } as Pick<
CreateFormValues, CreateFormValues,
'name' | 'category' | 'oneliner' 'name' | 'category' | 'oneliner' | 'command'
>; >;
if ((oneliner?.length || 0) > 0) { if ((oneliner?.length || 0) > 0) {
groupData.oneliner = oneliner; groupData.oneliner = oneliner;
} }
if ((command?.length || 0) > 0) {
groupData.command = command;
}
createPromptMutation.mutate({ createPromptMutation.mutate({
prompt: rest, prompt: rest,
group: groupData, group: groupData,
@ -121,15 +127,15 @@ const CreatePromptForm = ({
</div> </div>
)} )}
/> />
<CategorySelector tabIndex={4} /> <CategorySelector tabIndex={5} />
</div> </div>
</div> </div>
<div className="w-full md:mt-[1.075rem]"> <div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div> <div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 pr-1 text-base font-semibold dark:border-gray-600 dark:text-gray-200"> <h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 pr-1 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_prompt_text')}* {localize('com_ui_prompt_text')}*
</h2> </h2>
<div className="mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600"> <div className="min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<Controller <Controller
name="prompt" name="prompt"
control={control} control={control}
@ -159,9 +165,10 @@ const CreatePromptForm = ({
onValueChange={(value) => methods.setValue('oneliner', value)} onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={3} tabIndex={3}
/> />
<Command onValueChange={(value) => methods.setValue('command', value)} tabIndex={4} />
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<Button <Button
tabIndex={5} tabIndex={6}
type="submit" type="submit"
variant="default" variant="default"
disabled={!isDirty || isSubmitting || !isValid} disabled={!isDirty || isSubmitting || !isValid}

View file

@ -7,7 +7,7 @@ import VariableForm from './VariableForm';
interface VariableDialogProps extends Omit<DialogPrimitive.DialogProps, 'onOpenChange'> { interface VariableDialogProps extends Omit<DialogPrimitive.DialogProps, 'onOpenChange'> {
onClose: () => void; onClose: () => void;
group: TPromptGroup; group: TPromptGroup | null;
} }
const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group }) => { const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group }) => {
@ -18,9 +18,13 @@ const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group })
}; };
const hasVariables = useMemo( const hasVariables = useMemo(
() => detectVariables(group.productionPrompt?.prompt ?? ''), () => detectVariables(group?.productionPrompt?.prompt ?? ''),
[group.productionPrompt?.prompt], [group?.productionPrompt?.prompt],
); );
if (!group) {
return null;
}
if (!hasVariables) { if (!hasVariables) {
return null; return null;
} }

View file

@ -3,6 +3,7 @@ import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables'; import PromptVariables from './PromptVariables';
import Description from './Description'; import Description from './Description';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import Command from './Command';
const PromptDetails = ({ group }: { group: TPromptGroup }) => { const PromptDetails = ({ group }: { group: TPromptGroup }) => {
const localize = useLocalize(); const localize = useLocalize();
@ -27,17 +28,18 @@ const PromptDetails = ({ group }: { group: TPromptGroup }) => {
</div> </div>
</div> </div>
<div className="flex h-full w-full flex-col md:flex-row"> <div className="flex h-full w-full flex-col md:flex-row">
<div className="flex-1 overflow-y-auto border-gray-300 p-0 dark:border-gray-600 md:max-h-[calc(100vh-150px)] md:p-4"> <div className="flex flex-1 flex-col gap-4 overflow-y-auto border-gray-300 p-0 dark:border-gray-600 md:max-h-[calc(100vh-150px)] md:p-4">
<div> <div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 text-base font-semibold dark:border-gray-600 dark:text-gray-200"> <h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_prompt_text')} {localize('com_ui_prompt_text')}
</h2> </h2>
<div className="group relative mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600"> <div className="group relative min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<span className="block break-words px-2 py-1 dark:text-gray-200">{promptText}</span> <span className="block break-words px-2 py-1 dark:text-gray-200">{promptText}</span>
</div> </div>
</div> </div>
<PromptVariables promptText={promptText} /> <PromptVariables promptText={promptText} />
<Description initialValue={group.oneliner} disabled={true} /> <Description initialValue={group.oneliner} disabled={true} />
<Command initialValue={group.command} disabled={true} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -50,7 +50,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
</h2> </h2>
<div <div
className={cn( className={cn(
'group relative mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 hover:opacity-90 dark:border-gray-600', 'group relative min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 hover:opacity-90 dark:border-gray-600',
{ 'cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-100/10': !isEditing }, { 'cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-100/10': !isEditing },
)} )}
onClick={() => !isEditing && setIsEditing(true)} onClick={() => !isEditing && setIsEditing(true)}

View file

@ -14,12 +14,13 @@ import {
useUpdatePromptGroup, useUpdatePromptGroup,
useMakePromptProduction, useMakePromptProduction,
} from '~/data-provider'; } from '~/data-provider';
import { useAuthContext, usePromptGroupsNav, useHasAccess } from '~/hooks'; import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
import CategorySelector from './Groups/CategorySelector'; import CategorySelector from './Groups/CategorySelector';
import AlwaysMakeProd from './Groups/AlwaysMakeProd'; import AlwaysMakeProd from './Groups/AlwaysMakeProd';
import NoPromptGroup from './Groups/NoPromptGroup'; import NoPromptGroup from './Groups/NoPromptGroup';
import { Button, Skeleton } from '~/components/ui'; import { Button, Skeleton } from '~/components/ui';
import PromptVariables from './PromptVariables'; import PromptVariables from './PromptVariables';
import { useToastContext } from '~/Providers';
import PromptVersions from './PromptVersions'; import PromptVersions from './PromptVersions';
import DeleteConfirm from './DeleteVersion'; import DeleteConfirm from './DeleteVersion';
import PromptDetails from './PromptDetails'; import PromptDetails from './PromptDetails';
@ -29,6 +30,7 @@ import SkeletonForm from './SkeletonForm';
import Description from './Description'; import Description from './Description';
import SharePrompt from './SharePrompt'; import SharePrompt from './SharePrompt';
import PromptName from './PromptName'; import PromptName from './PromptName';
import Command from './Command';
import store from '~/store'; import store from '~/store';
const { PromptsEditorMode, promptsEditorMode } = store; const { PromptsEditorMode, promptsEditorMode } = store;
@ -36,7 +38,10 @@ const { PromptsEditorMode, promptsEditorMode } = store;
const PromptForm = () => { const PromptForm = () => {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext(); const { user } = useAuthContext();
const { showToast } = useToastContext();
const editorMode = useRecoilValue(promptsEditorMode); const editorMode = useRecoilValue(promptsEditorMode);
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd); const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(params.promptId || ''); const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(params.promptId || '');
@ -101,7 +106,14 @@ const PromptForm = () => {
setSelectionIndex(0); setSelectionIndex(0);
}, },
}); });
const updateGroupMutation = useUpdatePromptGroup(); const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
showToast({
status: 'error',
message: localize('com_ui_prompt_update_error'),
});
},
});
const makeProductionMutation = useMakePromptProduction(); const makeProductionMutation = useMakePromptProduction();
const deletePromptMutation = useDeletePrompt({ const deletePromptMutation = useDeletePrompt({
onSuccess: (response) => { onSuccess: (response) => {
@ -175,6 +187,17 @@ const PromptForm = () => {
[updateGroupMutation, group], [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<ReturnType<typeof usePromptGroupsNav>>(); const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>();
if (initialLoad) { if (initialLoad) {
@ -282,14 +305,18 @@ const PromptForm = () => {
{isLoadingPrompts ? ( {isLoadingPrompts ? (
<Skeleton className="h-96" /> <Skeleton className="h-96" />
) : ( ) : (
<> <div className="flex flex-col gap-4">
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} /> <PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
<PromptVariables promptText={promptText} /> <PromptVariables promptText={promptText} />
<Description <Description
initialValue={group?.oneliner ?? ''} initialValue={group?.oneliner ?? ''}
onValueChange={debouncedUpdateOneliner} onValueChange={debouncedUpdateOneliner}
/> />
</> <Command
initialValue={group?.command ?? ''}
onValueChange={debouncedUpdateCommand}
/>
</div>
)} )}
</div> </div>
{/* Right Section */} {/* Right Section */}

View file

@ -20,12 +20,12 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
}, [promptText]); }, [promptText]);
return ( return (
<> <div>
<h3 className="flex items-center gap-2 rounded-t-lg border border-border-medium py-2 pl-4 text-base font-semibold text-text-secondary"> <h3 className="flex items-center gap-2 rounded-t-lg border border-border-medium py-2 pl-4 text-base font-semibold text-text-secondary">
<Variable className="icon-sm" /> <Variable className="icon-sm" />
{localize('com_ui_variables')} {localize('com_ui_variables')}
</h3> </h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4 md:min-h-16"> <div className="flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4 md:min-h-16">
{variables.length ? ( {variables.length ? (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
{variables.map((variable, index) => ( {variables.map((variable, index) => (
@ -52,7 +52,7 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
{localize('com_ui_special_variables')} {localize('com_ui_special_variables')}
</span> </span>
</div> </div>
</> </div>
); );
}; };

View file

@ -5,9 +5,12 @@ import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { import {
/* Prompts */ /* Prompts */
addGroupToAll,
addPromptGroup, addPromptGroup,
updateGroupInAll,
updateGroupFields, updateGroupFields,
deletePromptGroup, deletePromptGroup,
removeGroupFromAll,
} from '~/utils'; } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -83,7 +86,12 @@ export const useUpdatePromptGroup = (
onError(err, variables, context); 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); return addPromptGroup(data, group);
}, },
); );
addGroupToAll(queryClient, group);
} }
if (onSuccess) { if (onSuccess) {
@ -151,6 +161,8 @@ export const useDeletePrompt = (
return deletePromptGroup(data, promptGroupId); return deletePromptGroup(data, promptGroupId);
}, },
); );
removeGroupFromAll(queryClient, promptGroupId);
} else { } else {
queryClient.setQueryData<t.TPrompt[]>( queryClient.setQueryData<t.TPrompt[]>(
[QueryKeys.prompts, variables.groupId], [QueryKeys.prompts, variables.groupId],
@ -208,6 +220,8 @@ export const useDeletePromptGroup = (
return deletePromptGroup(data, variables.id); return deletePromptGroup(data, variables.id);
}, },
); );
removeGroupFromAll(queryClient, variables.id);
if (onSuccess) { if (onSuccess) {
onSuccess(response, variables, context); onSuccess(response, variables, context);
} }
@ -299,6 +313,15 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions)
onError(err, variables, context); 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);
}
},
}); });
}; };

View file

@ -488,6 +488,23 @@ export const useGetPrompts = (
); );
}; };
export const useGetAllPromptGroups = <TData = t.AllPromptGroupsResponse>(
filter?: t.AllPromptGroupsFilterRequest,
config?: UseQueryOptions<t.AllPromptGroupsResponse, unknown, TData>,
): QueryObserverResult<TData> => {
return useQuery<t.AllPromptGroupsResponse, unknown, TData>(
[QueryKeys.allPromptGroups],
() => dataService.getAllPromptGroups(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
},
);
};
export const useGetCategories = <TData = t.TGetCategoriesResponse>( export const useGetCategories = <TData = t.TGetCategoriesResponse>(
config?: UseQueryOptions<t.TGetCategoriesResponse, unknown, TData>, config?: UseQueryOptions<t.TGetCategoriesResponse, unknown, TData>,
): QueryObserverResult<TData> => { ): QueryObserverResult<TData> => {

View file

@ -1,5 +1,9 @@
import { useSetRecoilState } from 'recoil';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil'; import type { SetterOrUpdater } from 'recoil';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import store from '~/store';
/** Event Keys that shouldn't trigger a command */ /** Event Keys that shouldn't trigger a command */
const invalidKeys = { const invalidKeys = {
@ -36,14 +40,21 @@ const shouldTriggerCommand = (
* Custom hook for handling key up events with command triggers. * Custom hook for handling key up events with command triggers.
*/ */
const useHandleKeyUp = ({ const useHandleKeyUp = ({
index,
textAreaRef, textAreaRef,
setShowPlusPopover, setShowPlusPopover,
setShowMentionPopover, setShowMentionPopover,
}: { }: {
index: number;
textAreaRef: React.RefObject<HTMLTextAreaElement>; textAreaRef: React.RefObject<HTMLTextAreaElement>;
setShowPlusPopover: SetterOrUpdater<boolean>; setShowPlusPopover: SetterOrUpdater<boolean>;
setShowMentionPopover: SetterOrUpdater<boolean>; setShowMentionPopover: SetterOrUpdater<boolean>;
}) => { }) => {
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const handleAtCommand = useCallback(() => { const handleAtCommand = useCallback(() => {
if (shouldTriggerCommand(textAreaRef, '@')) { if (shouldTriggerCommand(textAreaRef, '@')) {
setShowMentionPopover(true); setShowMentionPopover(true);
@ -56,12 +67,22 @@ const useHandleKeyUp = ({
} }
}, [textAreaRef, setShowPlusPopover]); }, [textAreaRef, setShowPlusPopover]);
const handlePromptsCommand = useCallback(() => {
if (!hasAccess) {
return;
}
if (shouldTriggerCommand(textAreaRef, '/')) {
setShowPromptsPopover(true);
}
}, [textAreaRef, hasAccess, setShowPromptsPopover]);
const commandHandlers = useMemo( const commandHandlers = useMemo(
() => ({ () => ({
'@': handleAtCommand, '@': handleAtCommand,
'+': handlePlusCommand, '+': handlePlusCommand,
'/': handlePromptsCommand,
}), }),
[handleAtCommand, handlePlusCommand], [handleAtCommand, handlePlusCommand, handlePromptsCommand],
); );
/** /**

View file

@ -32,22 +32,13 @@ export default function useSideNavLinks({
endpoint?: EModelEndpoint | null; endpoint?: EModelEndpoint | null;
interfaceConfig: Partial<TInterfaceConfig>; interfaceConfig: Partial<TInterfaceConfig>;
}) { }) {
const hasAccess = useHasAccess({ const hasAccessToPrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS, permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE, permission: Permissions.USE,
}); });
const Links = useMemo(() => { const Links = useMemo(() => {
const links: NavLink[] = []; const links: NavLink[] = [];
if (hasAccess) {
links.push({
title: 'com_ui_prompts',
label: '',
icon: MessageSquareQuote,
id: 'prompts',
Component: PromptsAccordion,
});
}
if ( if (
isAssistantsEndpoint(endpoint) && isAssistantsEndpoint(endpoint) &&
assistants && 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({ links.push({
title: 'com_sidepanel_attach_files', title: 'com_sidepanel_attach_files',
label: '', label: '',
@ -81,7 +82,14 @@ export default function useSideNavLinks({
}); });
return links; return links;
}, [assistants, keyProvided, hidePanel, endpoint, interfaceConfig.parameters, hasAccess]); }, [
assistants,
keyProvided,
hidePanel,
endpoint,
interfaceConfig.parameters,
hasAccessToPrompts,
]);
return Links; return Links;
} }

View file

@ -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.', '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_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_no_base_url: 'No base URL found. Please provide one and try again.',
com_error_invalid_user_key: com_error_invalid_user_key: 'Invalid key provided. Please provide a valid key and try again.',
'Invalid key provided. Please provide a valid key and try again.',
com_error_expired_user_key: com_error_expired_user_key:
'Provided key for {0} expired at {1}. Please provide a new key and try again.', 'Provided key for {0} expired at {1}. Please provide a new key and try again.',
com_files_no_results: 'No results.', com_files_no_results: 'No results.',
@ -247,8 +246,11 @@ export default {
com_ui_prompts_allow_create: 'Allow creating Prompts', com_ui_prompts_allow_create: 'Allow creating Prompts',
com_ui_prompts_allow_share_global: 'Allow sharing Prompts to all users', 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_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_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_no_prompt_description: 'No description found.',
com_ui_share_link_to_chat: 'Share link to chat', com_ui_share_link_to_chat: 'Share link to chat',
com_ui_share_error: 'There was an error sharing the chat link', 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.', '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: 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.', '`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: 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.', '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: com_nav_info_latex_parsing:

View file

@ -21,7 +21,7 @@ import AdminSettings from '~/components/Prompts/AdminSettings';
import { useDashboardContext } from '~/Providers'; import { useDashboardContext } from '~/Providers';
import store from '~/store'; import store from '~/store';
const promptsPathPattern = /prompts\/.*/; const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/;
const getConversationId = (prevLocationPath: string) => { const getConversationId = (prevLocationPath: string) => {
if (!prevLocationPath || prevLocationPath.includes('/d/')) { if (!prevLocationPath || prevLocationPath.includes('/d/')) {

View file

@ -173,6 +173,11 @@ const showPlusPopoverFamily = atomFamily<boolean, string | number | null>({
default: false, default: false,
}); });
const showPromptsPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showPromptsPopoverByIndex',
default: false,
});
const globalAudioURLFamily = atomFamily<string | null, string | number | null>({ const globalAudioURLFamily = atomFamily<string | null, string | number | null>({
key: 'globalAudioURLByIndex', key: 'globalAudioURLByIndex',
default: null, default: null,
@ -326,4 +331,5 @@ export default {
activePromptByIndex, activePromptByIndex,
useClearSubmissionState, useClearSubmissionState,
useClearLatestMessages, useClearLatestMessages,
showPromptsPopoverFamily,
}; };

View file

@ -32,6 +32,7 @@ html {
--surface-primary-contrast:var(--gray-100); --surface-primary-contrast:var(--gray-100);
--surface-secondary:var(--gray-50); --surface-secondary:var(--gray-50);
--surface-tertiary:var(--gray-100); --surface-tertiary:var(--gray-100);
--surface-tertiary-alt:var(--white);
--border-light:var(--gray-100); --border-light:var(--gray-100);
--border-medium-alt:var(--gray-300); --border-medium-alt:var(--gray-300);
--border-medium:var(--gray-200); --border-medium:var(--gray-200);
@ -48,6 +49,7 @@ html {
--surface-primary-contrast:var(--gray-850); --surface-primary-contrast:var(--gray-850);
--surface-secondary:var(--gray-800); --surface-secondary:var(--gray-800);
--surface-tertiary:var(--gray-700); --surface-tertiary:var(--gray-700);
--surface-tertiary-alt:var(--gray-700);
--border-light:var(--gray-700); --border-light:var(--gray-700);
--border-medium-alt:var(--gray-600); --border-medium-alt:var(--gray-600);
--border-medium:var(--gray-600); --border-medium:var(--gray-600);

View file

@ -1,4 +1,4 @@
import { InfiniteData } from '@tanstack/react-query'; import { InfiniteData, QueryClient } from '@tanstack/react-query';
export const addData = <TCollection, TData>( export const addData = <TCollection, TData>(
data: InfiniteData<TCollection>, data: InfiniteData<TCollection>,
@ -165,3 +165,56 @@ export const updateFields = <TCollection, TData>(
return newData; return newData;
}; };
type UpdateCacheListOptions<TData> = {
queryClient: QueryClient;
queryKey: unknown[];
searchProperty: keyof TData;
updateData: Partial<TData>;
searchValue: unknown;
};
export function updateCacheList<TData>({
queryClient,
queryKey,
searchProperty,
updateData,
searchValue,
}: UpdateCacheListOptions<TData>) {
queryClient.setQueryData<TData[]>(queryKey, (oldData) => {
if (!oldData) {
return oldData;
}
return oldData.map((item) =>
item[searchProperty] === searchValue ? { ...item, ...updateData } : item,
);
});
}
export function addToCacheList<TData>(
queryClient: QueryClient,
queryKey: unknown[],
newItem: TData,
) {
queryClient.setQueryData<TData[]>(queryKey, (oldData) => {
if (!oldData) {
return [newItem];
}
return [...oldData, newItem];
});
}
export function removeFromCacheList<TData>(
queryClient: QueryClient,
queryKey: unknown[],
searchProperty: keyof TData,
searchValue: unknown,
) {
queryClient.setQueryData<TData[]>(queryKey, (oldData) => {
if (!oldData) {
return oldData;
}
return oldData.filter((item) => item[searchProperty] !== searchValue);
});
}

View file

@ -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 { import type {
PromptGroupListResponse, PromptGroupListResponse,
PromptGroupListData, PromptGroupListData,
TPromptGroup, TPromptGroup,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { addData, deleteData, updateData, updateFields, getRecordByProperty } from './collection'; import {
import { InfiniteData } from '@tanstack/react-query'; addData,
deleteData,
updateData,
updateFields,
addToCacheList,
updateCacheList,
removeFromCacheList,
getRecordByProperty,
} from './collection';
export const addPromptGroup = ( export const addPromptGroup = (
data: InfiniteData<PromptGroupListResponse>, data: InfiniteData<PromptGroupListResponse>,
@ -70,3 +79,24 @@ export const findPromptGroup = (
findProperty, findProperty,
); );
}; };
export const addGroupToAll = (queryClient: QueryClient, newGroup: TPromptGroup) => {
addToCacheList<TPromptGroup>(queryClient, [QueryKeys.allPromptGroups], newGroup);
};
export const updateGroupInAll = (
queryClient: QueryClient,
updatedGroup: Partial<TPromptGroup> & { _id: string },
) => {
updateCacheList<TPromptGroup>({
queryClient,
queryKey: [QueryKeys.allPromptGroups],
searchProperty: '_id',
updateData: updatedGroup,
searchValue: updatedGroup._id,
});
};
export const removeGroupFromAll = (queryClient: QueryClient, groupId: string) => {
removeFromCacheList<TPromptGroup>(queryClient, [QueryKeys.allPromptGroups], '_id', groupId);
};

View file

@ -1,5 +1,5 @@
import { format } from 'date-fns'; 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 }) { export function replaceSpecialVars({ text, user }: { text: string; user?: TUser }) {
if (!text) { if (!text) {
@ -92,3 +92,13 @@ export function formatDateTime(dateTimeString: string) {
return `${formattedDate}, ${formattedTime}`; return `${formattedDate}, ${formattedTime}`;
} }
export const mapPromptGroups = (groups: TPromptGroup[]): Record<string, TPromptGroup> => {
return groups.reduce((acc, group) => {
if (!group._id) {
return acc;
}
acc[group._id] = group;
return acc;
}, {} as Record<string, TPromptGroup>);
};

View file

@ -73,6 +73,7 @@ module.exports = {
'surface-primary-contrast': 'var(--surface-primary-contrast)', 'surface-primary-contrast': 'var(--surface-primary-contrast)',
'surface-secondary': 'var(--surface-secondary)', 'surface-secondary': 'var(--surface-secondary)',
'surface-tertiary': 'var(--surface-tertiary)', 'surface-tertiary': 'var(--surface-tertiary)',
'surface-tertiary-alt': 'var(--surface-tertiary-alt)',
'border-light': 'var(--border-light)', 'border-light': 'var(--border-light)',
'border-medium': 'var(--border-medium)', 'border-medium': 'var(--border-medium)',
'border-medium-alt': 'var(--border-medium-alt)', 'border-medium-alt': 'var(--border-medium-alt)',

View file

@ -8,50 +8,48 @@ const connect = require('./connect');
(async () => { (async () => {
await connect(); await connect();
/**
* Show the welcome / help menu
*/
console.purple('--------------------------'); console.purple('--------------------------');
console.purple('Create a new user account!'); console.purple('Create a new user account!');
console.purple('--------------------------'); console.purple('--------------------------');
// If we don't have enough arguments, show the help menu
if (process.argv.length < 5) { if (process.argv.length < 5) {
console.orange('Usage: npm run create-user <email> <name> <username>'); console.orange('Usage: npm run create-user <email> <name> <username> [--email-verified=false]');
console.orange('Note: if you do not pass in the arguments, you will be prompted for them.'); console.orange('Note: if you do not pass in the arguments, you will be prompted for them.');
console.orange( console.orange(
'If you really need to pass in the password, you can do so as the 4th argument (not recommended for security).', '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('--------------------------'); console.purple('--------------------------');
} }
/**
* Set up the variables we need and get the arguments if they were passed in
*/
let email = ''; let email = '';
let password = ''; let password = '';
let name = ''; let name = '';
let username = ''; let username = '';
// If we have the right number of arguments, lets use them let emailVerified = true;
if (process.argv.length >= 4) {
email = process.argv[2];
name = process.argv[3];
if (process.argv.length >= 5) { // Parse command line arguments
username = process.argv[4]; 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!'); 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) { if (!email) {
email = await askQuestion('Email:'); email = await askQuestion('Email:');
} }
// Validate the email
if (!email.includes('@')) { if (!email.includes('@')) {
console.red('Error: Invalid email address!'); console.red('Error: Invalid email address!');
silentExit(1); silentExit(1);
@ -73,41 +71,51 @@ const connect = require('./connect');
if (!password) { if (!password) {
password = await askQuestion('Password: (leave blank, to generate one)'); password = await askQuestion('Password: (leave blank, to generate one)');
if (!password) { if (!password) {
// Make it a random password, length 18
password = Math.random().toString(36).slice(-18); password = Math.random().toString(36).slice(-18);
console.orange('Your password is: ' + password); 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 }] }); const userExists = await User.findOne({ $or: [{ email }, { username }] });
if (userExists) { if (userExists) {
console.red('Error: A user with that email or username already exists!'); console.red('Error: A user with that email or username already exists!');
silentExit(1); silentExit(1);
} }
/**
* Now that we have all the variables we need, lets create the user
*/
const user = { email, password, name, username, confirm_password: password }; const user = { email, password, name, username, confirm_password: password };
let result; let result;
try { try {
result = await registerUser(user); result = await registerUser(user, { emailVerified });
} catch (error) { } catch (error) {
console.red('Error: ' + error.message); console.red('Error: ' + error.message);
silentExit(1); silentExit(1);
} }
// Check the result
if (result.status !== 200) { if (result.status !== 200) {
console.red('Error: ' + result.message); console.red('Error: ' + result.message);
silentExit(1); silentExit(1);
} }
// Done!
const userCreated = await User.findOne({ $or: [{ email }, { username }] }); const userCreated = await User.findOne({ $or: [{ email }, { username }] });
if (userCreated) { if (userCreated) {
console.green('User created successfully!'); console.green('User created successfully!');
console.green(`Email verified: ${userCreated.emailVerified}`);
silentExit(0); silentExit(0);
} }
})(); })();

View file

@ -177,6 +177,8 @@ export const deletePrompt = ({ _id, groupId }: { _id: string; groupId: string })
export const getCategories = () => '/api/categories'; export const getCategories = () => '/api/categories';
export const getAllPromptGroups = () => `${prompts()}/all`;
/* Roles */ /* Roles */
export const roles = () => '/api/roles'; export const roles = () => '/api/roles';
export const getRole = (roleName: string) => `${roles()}/${roleName.toLowerCase()}`; export const getRole = (roleName: string) => `${roles()}/${roleName.toLowerCase()}`;

View file

@ -822,6 +822,8 @@ export enum Constants {
CURRENT_MODEL = 'current_model', CURRENT_MODEL = 'current_model',
/** Common divider for text values */ /** Common divider for text values */
COMMON_DIVIDER = '__', COMMON_DIVIDER = '__',
/** Max length for commands */
COMMANDS_MAX_LENGTH = 56,
} }
export enum LocalStorageKeys { export enum LocalStorageKeys {

View file

@ -475,6 +475,10 @@ export function getPrompts(filter: t.TPromptsWithFilterRequest): Promise<t.TProm
return request.get(endpoints.getPromptsWithFilters(filter)); return request.get(endpoints.getPromptsWithFilters(filter));
} }
export function getAllPromptGroups(): Promise<q.AllPromptGroupsResponse> {
return request.get(endpoints.getAllPromptGroups());
}
export function getPromptGroups( export function getPromptGroups(
filter: t.TPromptGroupsWithFilterRequest, filter: t.TPromptGroupsWithFilterRequest,
): Promise<t.PromptGroupListResponse> { ): Promise<t.PromptGroupListResponse> {

View file

@ -30,6 +30,7 @@ export enum QueryKeys {
prompts = 'prompts', prompts = 'prompts',
prompt = 'prompt', prompt = 'prompt',
promptGroups = 'promptGroups', promptGroups = 'promptGroups',
allPromptGroups = 'allPromptGroups',
promptGroup = 'promptGroup', promptGroup = 'promptGroup',
categories = 'categories', categories = 'categories',
randomPrompts = 'randomPrompts', randomPrompts = 'randomPrompts',

View file

@ -351,6 +351,7 @@ export type TPrompt = {
export type TPromptGroup = { export type TPromptGroup = {
name: string; name: string;
numberOfGenerations?: number; numberOfGenerations?: number;
command?: string;
oneliner?: string; oneliner?: string;
category?: string; category?: string;
projectIds?: string[]; projectIds?: string[];
@ -365,7 +366,7 @@ export type TPromptGroup = {
export type TCreatePrompt = { export type TCreatePrompt = {
prompt: Pick<TPrompt, 'prompt' | 'type'> & { groupId?: string }; prompt: Pick<TPrompt, 'prompt' | 'type'> & { groupId?: string };
group?: { name: string; category?: string; oneliner?: string }; group?: { name: string; category?: string; oneliner?: string; command?: string };
}; };
export type TCreatePromptRecord = TCreatePrompt & Pick<TPromptGroup, 'author' | 'authorName'>; export type TCreatePromptRecord = TCreatePrompt & Pick<TPromptGroup, 'author' | 'authorName'>;
@ -385,6 +386,7 @@ export type TPromptGroupsWithFilterRequest = {
after?: string | null; after?: string | null;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
name?: string; name?: string;
author?: string;
}; };
export type PromptGroupListResponse = { export type PromptGroupListResponse = {

View file

@ -1,5 +1,6 @@
import type { InfiniteData } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query';
import type { TMessage, TConversation, TSharedLink } from '../schemas'; import type { TMessage, TConversation, TSharedLink } from '../schemas';
import type * as t from '../types';
export type Conversation = { export type Conversation = {
id: string; id: string;
createdAt: number; createdAt: number;
@ -54,3 +55,16 @@ export type SharedLinkListResponse = {
}; };
export type SharedLinkListData = InfiniteData<SharedLinkListResponse>; export type SharedLinkListData = InfiniteData<SharedLinkListResponse>;
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[];