🗨️ 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

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

View file

@ -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}
/>
)}
<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">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<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 { 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 = ({
</div>
)}
/>
<CategorySelector tabIndex={4} />
<CategorySelector tabIndex={5} />
</div>
</div>
<div className="w-full md:mt-[1.075rem]">
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<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">
{localize('com_ui_prompt_text')}*
</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
name="prompt"
control={control}
@ -159,9 +165,10 @@ const CreatePromptForm = ({
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={3}
/>
<Command onValueChange={(value) => methods.setValue('command', value)} tabIndex={4} />
<div className="mt-4 flex justify-end">
<Button
tabIndex={5}
tabIndex={6}
type="submit"
variant="default"
disabled={!isDirty || isSubmitting || !isValid}

View file

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

View file

@ -3,6 +3,7 @@ import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import Description from './Description';
import { useLocalize } from '~/hooks';
import Command from './Command';
const PromptDetails = ({ group }: { group: TPromptGroup }) => {
const localize = useLocalize();
@ -27,17 +28,18 @@ const PromptDetails = ({ group }: { group: TPromptGroup }) => {
</div>
</div>
<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>
<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')}
</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>
</div>
</div>
<PromptVariables promptText={promptText} />
<Description initialValue={group.oneliner} disabled={true} />
<Command initialValue={group.command} disabled={true} />
</div>
</div>
</div>

View file

@ -50,7 +50,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
</h2>
<div
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 },
)}
onClick={() => !isEditing && setIsEditing(true)}

View file

@ -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<ReturnType<typeof usePromptGroupsNav>>();
if (initialLoad) {
@ -282,14 +305,18 @@ const PromptForm = () => {
{isLoadingPrompts ? (
<Skeleton className="h-96" />
) : (
<>
<div className="flex flex-col gap-4">
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
<PromptVariables promptText={promptText} />
<Description
initialValue={group?.oneliner ?? ''}
onValueChange={debouncedUpdateOneliner}
/>
</>
<Command
initialValue={group?.command ?? ''}
onValueChange={debouncedUpdateCommand}
/>
</div>
)}
</div>
{/* Right Section */}

View file

@ -20,12 +20,12 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
}, [promptText]);
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">
<Variable className="icon-sm" />
{localize('com_ui_variables')}
</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 ? (
<div className="flex h-7 items-center">
{variables.map((variable, index) => (
@ -52,7 +52,7 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
{localize('com_ui_special_variables')}
</span>
</div>
</>
</div>
);
};

View file

@ -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<t.TPrompt[]>(
[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);
}
},
});
};

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>(
config?: UseQueryOptions<t.TGetCategoriesResponse, unknown, TData>,
): QueryObserverResult<TData> => {

View file

@ -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<HTMLTextAreaElement>;
setShowPlusPopover: SetterOrUpdater<boolean>;
setShowMentionPopover: SetterOrUpdater<boolean>;
}) => {
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],
);
/**

View file

@ -32,22 +32,13 @@ export default function useSideNavLinks({
endpoint?: EModelEndpoint | null;
interfaceConfig: Partial<TInterfaceConfig>;
}) {
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;
}

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.',
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:

View file

@ -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/')) {

View file

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

View file

@ -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);

View file

@ -1,4 +1,4 @@
import { InfiniteData } from '@tanstack/react-query';
import { InfiniteData, QueryClient } from '@tanstack/react-query';
export const addData = <TCollection, TData>(
data: InfiniteData<TCollection>,
@ -165,3 +165,56 @@ export const updateFields = <TCollection, TData>(
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 {
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<PromptGroupListResponse>,
@ -70,3 +79,24 @@ export const findPromptGroup = (
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 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<string, TPromptGroup> => {
return groups.reduce((acc, group) => {
if (!group._id) {
return acc;
}
acc[group._id] = group;
return acc;
}, {} as Record<string, TPromptGroup>);
};