🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements (#7123)

* wip: Add Instructions component for agent configuration

*  feat: Implement DropdownPopup for variable insertion in instructions

* refactor: Enhance variable handling by exporting specialVariables and updating Markdown components

* feat: Add special variable support for current date and user in Instructions component

* refactor: Update handleAddVariable to include localized label

* feat: replace special variables in instructions presets

* chore: update parameter type for user in getListAgents function

* refactor: integrate dayjs for date handling and move replaceSpecialVars function to data-provider

* feat: enhance replaceSpecialVars to include day number in current date format

* feat: integrate replaceSpecialVars for processing agent instructions

* feat: add support for current date & time in replaceSpecialVars function

* feat: add iso_datetime support in replaceSpecialVars function

* fix: enforce text parameter to be a required field in replaceSpecialVars function

* feat: add ISO datetime support in translation file

* fix: disable eslint warning for autoFocus in TextareaAutosize component

* feat: add VariablesDropdown component and integrate it into CreatePromptForm and PromptEditor; update translation for special variables

* fix: CategorySelector and related localizations

* fix: add z-index class to LanguageSTTDropdown for proper stacking context

* fix: add max-height and overflow styles to OGDialogContent in VariableDialog and PreviewPrompt components

* fix: update variable detection logic to exclude special variables and improve regex matching

* fix: improve accessibility text for actions menu in ChatGroupItem component

* fix: adjust max-width and height styles for dialog components and improve markdown rendering for light vs. dark, height/widths, etc.

* fix: remove commented-out code for better readability in PromptVariableGfm component

* fix: handle undefined input parameter in setParams function call

* fix: update variable label types to use TSpecialVarLabel for consistency

* fix: remove outdated information from special variables description in translation file

* fix: enhance unused i18next keys detection for special variable keys

* fix: update color classes for consistency/a11y in category and prompt variable components

* fix: update PromptVariableGfm component and special variable styles for consistency

* fix: improve variable highlighting logic in VariableForm component

* fix: update background color classes for consistency in VariableForm component

* fix: add missing ref parameter to Dialog component in OriginalDialog

* refactor: move navigate call for new conversation to after setConversation update

* refactor: move message query hook to client workspace; fix: handle edge case for navigation from finalHandler creating race condition for response message DB save

* chore: bump librechat-data-provider to 0.7.793

* ci: add unit tests for replaceSpecialVars function

* fix: implement getToolkitKey function for image_gen_oai toolkit filtering/including

* ci: enhance dayjs mock for consistent date/time values in tests

* fix: MCP stdio server fail to start when passing env property

* fix: use optional chaining for clientRef dereferencing in AskController and EditController
feat: add context to saveMessage call in streamResponse utility

* fix: only save error messages if the userMessageId was initialized

* refactor: add isNotAppendable check to disable inputs in ChatForm and useTextarea

* feat: enhance error handling in useEventHandlers and update conversation state in useNewConvo

* refactor: prepend underscore to conversationId in newConversation template

* feat: log aborted conversations with minimal messages and use consistent conversationId generation

---------

Co-authored-by: Olivier Schiavo <olivier.schiavo@wengo.com>
Co-authored-by: aka012 <aka012@neowiz.com>
Co-authored-by: jiasheng <jiashengguo@outlook.com>
This commit is contained in:
Danny Avila 2025-04-29 03:49:02 -04:00 committed by GitHub
parent 0e8041bcac
commit 55f5f2d11a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 707 additions and 195 deletions

View file

@ -1,14 +1,14 @@
import { memo, useMemo, useCallback } from 'react';
import { memo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import { Spinner } from '~/components/svg';
import Presentation from './Presentation';

View file

@ -132,7 +132,13 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
setShowPlusPopover,
setShowMentionPopover,
});
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
const {
isNotAppendable,
handlePaste,
handleKeyDown,
handleCompositionStart,
handleCompositionEnd,
} = useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
@ -251,7 +257,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs}
disabled={disableInputs || isNotAppendable}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
@ -271,7 +277,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200',
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
@ -306,7 +312,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={disableInputs}
disabled={disableInputs || isNotAppendable}
isSubmitting={isSubmitting}
/>
)}
@ -318,7 +324,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs}
disabled={filesLoading || isSubmitting || disableInputs || isNotAppendable}
/>
)
)}

View file

@ -201,7 +201,7 @@ function PromptsCommand({
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}

View file

@ -102,8 +102,8 @@ export default function LanguageSTTDropdown() {
onChange={handleSelect}
options={languageOptions}
sizeClasses="[--anchor-max-height:256px]"
anchor="bottom start"
testId="LanguageSTTDropdown"
className="z-50"
/>
</div>
);

View file

@ -28,9 +28,9 @@ const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-300',
idea: 'text-yellow-500/90 dark:text-yellow-300 ',
write: 'text-purple-400',
travel: 'text-yellow-300',
travel: 'text-yellow-500/90 dark:text-yellow-300 ',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',

View file

@ -1,4 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ReactNode } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { LocalStorageKeys } from 'librechat-data-provider';
import { Dropdown } from '~/components/ui';
@ -15,6 +17,7 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
onValueChange,
className = '',
}) => {
const { t } = useTranslation();
const formContext = useFormContext();
const { categories, emptyCategory } = useCategories();
@ -32,13 +35,25 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
[watchedCategory, categories, currentCategory, emptyCategory],
);
const displayCategory = useMemo(() => {
if (!categoryOption.value && !('icon' in categoryOption)) {
return {
...categoryOption,
icon: (<span className="i-heroicons-tag" />) as ReactNode,
label: categoryOption.label || t('com_ui_empty_category'),
};
}
return categoryOption;
}, [categoryOption, t]);
return formContext ? (
<Controller
name="category"
control={control}
render={() => (
<Dropdown
value={categoryOption.value ?? ''}
value={displayCategory.value ?? ''}
label={displayCategory.value ? undefined : t('com_ui_category')}
onChange={(value: string) => {
setValue('category', value, { shouldDirty: false });
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
@ -48,10 +63,12 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={(option) => (
renderValue={() => (
<div className="flex items-center space-x-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span>{option.label}</span>
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/>
@ -68,10 +85,12 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={(option) => (
renderValue={() => (
<div className="flex items-center space-x-2">
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span>{option.label}</span>
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
</div>
)}
/>

View file

@ -57,7 +57,7 @@ function ChatGroupItem({
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: group.productionPrompt?.prompt ?? ''
: (group.productionPrompt?.prompt ?? '')
}
>
<div className="flex flex-row items-center gap-2">
@ -83,7 +83,11 @@ function ChatGroupItem({
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">Open actions menu for {group.name}</span>
<span className="sr-only">
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent

View file

@ -7,6 +7,7 @@ import PromptVariables from '~/components/Prompts/PromptVariables';
import { Button, TextareaAutosize, Input } from '~/components/ui';
import Description from '~/components/Prompts/Description';
import { useLocalize, useHasAccess } from '~/hooks';
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
@ -132,7 +133,8 @@ const CreatePromptForm = ({
<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-border-medium py-2 pl-4 pr-1 text-base font-semibold dark:text-gray-200">
{localize('com_ui_prompt_text')}*
<span>{localize('com_ui_prompt_text')}*</span>
<VariablesDropdown fieldName="prompt" className="mr-2" />
</h2>
<div className="min-h-32 rounded-b-lg border border-border-medium p-4 transition-all duration-150">
<Controller

View file

@ -31,7 +31,7 @@ const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group })
return (
<OGDialog open={open} onOpenChange={handleOpenChange}>
<OGDialogContent className="max-w-full bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-3xl">
<OGDialogContent className="max-h-[90vh] max-w-full overflow-y-auto bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-[60vw]">
<OGDialogTitle>{group.name}</OGDialogTitle>
<VariableForm group={group} onClose={onClose} />
</OGDialogContent>

View file

@ -5,18 +5,14 @@ import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
import type { TPromptGroup } from 'librechat-data-provider';
import {
cn,
wrapVariable,
defaultTextProps,
replaceSpecialVars,
extractVariableInfo,
} from '~/utils';
import { cn, wrapVariable, defaultTextProps, extractVariableInfo } from '~/utils';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { TextareaAutosize, InputCombobox, Button } from '~/components/ui';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { PromptVariableGfm } from '../Markdown';
type FieldType = 'text' | 'select';
@ -115,9 +111,12 @@ export default function VariableForm({
allVariables.forEach((variable) => {
const placeholder = `{{${variable}}}`;
const fieldIndex = variableIndexMap.get(variable) as string | number;
const fieldValue = fieldValues[fieldIndex].value as string;
const highlightText = fieldValue !== '' ? fieldValue : placeholder;
tempText = tempText.replaceAll(placeholder, `**${highlightText}**`);
const fieldValue = fieldValues[fieldIndex].value as string | undefined;
if (fieldValue === placeholder || fieldValue === '' || !fieldValue) {
return;
}
const highlightText = fieldValue !== '' ? `**${fieldValue}**` : placeholder;
tempText = tempText.replaceAll(placeholder, highlightText);
});
return tempText;
};
@ -141,7 +140,7 @@ export default function VariableForm({
return (
<div className="mx-auto p-1 md:container">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-gray-100 p-4 text-text-secondary dark:bg-gray-700/50 sm:max-w-full md:max-h-80">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
@ -152,8 +151,8 @@ export default function VariableForm({
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1 max-h-[50vh] break-words"
components={{ code: codeNoExecution, p: PromptVariableGfm }}
className="markdown prose dark:prose-invert light my-1 max-h-[50vh] max-w-full break-words dark:text-text-secondary"
>
{generateHighlightedMarkdown()}
</ReactMarkdown>

View file

@ -1,7 +1,7 @@
import React from 'react';
import { handleDoubleClick } from '~/utils';
export const CodeVariableGfm = ({ children }: { children: React.ReactNode }) => {
export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
return (
<code
onDoubleClick={handleDoubleClick}
@ -29,7 +29,10 @@ export const PromptVariableGfm = ({
const parts = child.split(regex);
return parts.map((part, index) =>
index % 2 === 1 ? (
<b key={index} className="rounded-md bg-yellow-100/90 p-1 text-gray-700">
<b
key={index}
className="ml-[0.5] rounded-lg bg-amber-100 p-[1px] font-medium text-yellow-800 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90"
>
{`{{${part}}}`}
</b>
) : (

View file

@ -13,7 +13,7 @@ const PreviewPrompt = ({
}) => {
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-5xl">
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
<div className="p-2">
<PromptDetails group={group} />
</div>

View file

@ -5,13 +5,13 @@ import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { useLocalize, useAuthContext } from '~/hooks';
import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import { PromptVariableGfm } from './Markdown';
import { replaceSpecialVars } from '~/utils';
import { Label } from '~/components/ui';
import Description from './Description';
import Command from './Command';
@ -46,7 +46,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
<div className="flex h-full max-h-screen flex-col overflow-y-auto md:flex-row">
<div className="flex flex-1 flex-col gap-4 p-0 md:max-h-[calc(100vh-150px)] md:p-2">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary ">
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary">
{localize('com_ui_prompt_text')}
</h2>
<div className="group relative min-h-32 rounded-b-lg border border-border-light p-4 transition-all duration-150">
@ -65,7 +65,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
]}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1"
className="markdown prose dark:prose-invert light dark:text-gray-70 my-1 break-words"
>
{mainText}
</ReactMarkdown>

View file

@ -12,6 +12,7 @@ import ReactMarkdown from 'react-markdown';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
import { SaveIcon, CrossIcon } from '~/components/svg';
import VariablesDropdown from './VariablesDropdown';
import { TextareaAutosize } from '~/components/ui';
import { PromptVariableGfm } from './Markdown';
import { PromptsEditorMode } from '~/common';
@ -59,10 +60,11 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
<span className="max-w-[200px] truncate sm:max-w-none">
{localize('com_ui_prompt_text')}
</span>
<div className="flex flex-shrink-0 flex-row gap-3 sm:gap-6">
<div className="flex flex-shrink-0 flex-row items-center gap-3 sm:gap-6">
{editorMode === PromptsEditorMode.ADVANCED && (
<AlwaysMakeProd className="hidden sm:flex" />
)}
<VariablesDropdown fieldName={name} />
<button
type="button"
onClick={() => setIsEditing((prev) => !prev)}
@ -105,6 +107,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
isEditing ? (
<TextareaAutosize
{...field}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="w-full resize-none overflow-y-auto rounded bg-transparent text-sm text-text-primary focus:outline-none sm:text-base"
minRows={3}
@ -123,8 +126,8 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
style={{ minHeight: '4.5em', maxHeight: '21em', overflow: 'auto' }}
>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],

View file

@ -1,18 +1,18 @@
import React, { useMemo } from 'react';
import { Variable } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { specialVariables } from 'librechat-data-provider';
import { cn, extractUniqueVariables } from '~/utils';
import { CodeVariableGfm } from './Markdown';
import { Separator } from '~/components/ui';
import { useLocalize } from '~/hooks';
const specialVariables = {
current_date: true,
current_user: true,
};
const specialVariableClasses =
'bg-yellow-500/25 text-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
'bg-amber-100 text-yellow-800 border-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
const components: {
[nodeType: string]: React.ElementType;
} = { code: CodeVariableGfm };
const PromptVariables = ({
promptText,
@ -52,7 +52,7 @@ const PromptVariables = ({
</div>
) : (
<div className="text-sm text-text-secondary">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_variables_info')}
</ReactMarkdown>
</div>
@ -65,8 +65,8 @@ const PromptVariables = ({
{localize('com_ui_special_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_special_variables_info')}
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_special_variables_more_info')}
</ReactMarkdown>
</span>
</div>
@ -75,7 +75,7 @@ const PromptVariables = ({
{localize('com_ui_dropdown_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_dropdown_variables_info')}
</ReactMarkdown>
</span>

View file

@ -0,0 +1,75 @@
import { useState, useId } from 'react';
import { PlusCircle } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { useFormContext } from 'react-hook-form';
import { specialVariables } from 'librechat-data-provider';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { DropdownPopup } from '~/components';
import { useLocalize } from '~/hooks';
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
interface VariablesDropdownProps {
fieldName?: string;
className?: string;
}
export default function VariablesDropdown({
fieldName = 'prompt',
className = '',
}: VariablesDropdownProps) {
const menuId = useId();
const localize = useLocalize();
const methods = useFormContext();
const { setValue, getValues } = methods;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const handleAddVariable = (label: TSpecialVarLabel, value: string) => {
const currentText = getValues(fieldName) || '';
const spacer = currentText.length > 0 ? '\n\n' : '';
const prefix = localize(label);
setValue(fieldName, currentText + spacer + prefix + ': ' + value);
setIsMenuOpen(false);
};
return (
<div
className={className}
title={`${localize('com_ui_add')} ${localize('com_ui_special_variables')}`}
>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label={`${localize('com_ui_add')} ${localize('com_ui_special_variables')}`}
className="flex h-8 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<PlusCircle className="mr-1 h-3 w-3 text-text-secondary" aria-hidden={true} />
{localize('com_ui_special_variables')}
</Menu.MenuButton>
}
items={variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}))}
menuId={menuId}
className="z-30"
/>
</div>
);
}

View file

@ -10,6 +10,7 @@ import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { icons } from '~/hooks/Endpoint/Icons';
import { processAgentOption } from '~/utils';
import Instructions from './Instructions';
import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
import { useLocalize } from '~/hooks';
@ -228,39 +229,7 @@ export default function AgentConfig({
/>
</div>
{/* Instructions */}
<div className="mb-4">
<label className={labelClass} htmlFor="instructions">
{localize('com_ui_instructions')}
</label>
<Controller
name="instructions"
control={control}
render={({ field, fieldState: { error } }) => (
<>
<textarea
{...field}
value={field.value ?? ''}
// maxLength={32768}
className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions"
placeholder={localize('com_agents_instructions_placeholder')}
rows={3}
aria-label="Agent instructions"
aria-required="true"
aria-invalid={error ? 'true' : 'false'}
/>
{error && (
<span
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
>
{localize('com_ui_field_required')}
</span>
)}
</>
)}
/>
</div>
<Instructions />
{/* Model and Provider */}
<div className="mb-4">
<label className={labelClass} htmlFor="provider">

View file

@ -0,0 +1,127 @@
import React, { useState, useId } from 'react';
import { PlusCircle } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { specialVariables } from 'librechat-data-provider';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { Controller, useFormContext } from 'react-hook-form';
import type { AgentForm } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
// import ControlCombobox from '~/components/ui/ControlCombobox';
import { DropdownPopup } from '~/components';
import { useLocalize } from '~/hooks';
const inputClass = cn(
defaultTextProps,
'flex w-full px-3 py-2 border-border-light bg-surface-secondary focus-visible:ring-2 focus-visible:ring-ring-primary',
removeFocusOutlines,
);
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
export default function Instructions() {
const menuId = useId();
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const handleAddVariable = (label: TSpecialVarLabel, value: string) => {
const currentInstructions = getValues('instructions') || '';
const spacer = currentInstructions.length > 0 ? '\n' : '';
const prefix = localize(label);
setValue('instructions', currentInstructions + spacer + prefix + ': ' + value);
setIsMenuOpen(false);
};
return (
<div className="mb-4">
<div className="mb-2 flex items-center">
<label className="text-token-text-primary flex-grow font-medium" htmlFor="instructions">
{localize('com_ui_instructions')}
</label>
<div className="ml-auto" title="Add variables to instructions">
{/* ControlCombobox implementation
<ControlCombobox
selectedValue=""
displayValue="Add variables"
items={variableOptions.map((option) => ({
label: option.label,
value: option.value,
}))}
setValue={handleAddVariable}
ariaLabel="Add variable to instructions"
searchPlaceholder="Search variables"
selectPlaceholder="Add"
isCollapsed={false}
SelectIcon={<PlusCircle className="h-3 w-3 text-text-secondary" />}
containerClassName="w-fit"
className="h-7 gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
iconSide="left"
showCarat={false}
/>
*/}
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label="Add variable to instructions"
className="flex h-7 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<PlusCircle className="mr-1 h-3 w-3 text-text-secondary" aria-hidden={true} />
{localize('com_ui_variables')}
</Menu.MenuButton>
}
items={variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}))}
menuId={menuId}
className="z-30"
/>
</div>
</div>
<Controller
name="instructions"
control={control}
render={({ field, fieldState: { error } }) => (
<>
<textarea
{...field}
value={field.value ?? ''}
className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions"
placeholder={localize('com_agents_instructions_placeholder')}
rows={3}
aria-label="Agent instructions"
aria-required="true"
aria-invalid={error ? 'true' : 'false'}
/>
{error && (
<span
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
>
{localize('com_ui_field_required')}
</span>
)}
</>
)}
/>
</div>
);
}

View file

@ -8,7 +8,7 @@ interface OGDialogProps extends DialogPrimitive.DialogProps {
}
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
({ children, triggerRef, onOpenChange, ...props }) => {
({ children, triggerRef, onOpenChange, ...props }, _ref) => {
const handleOpenChange = (open: boolean) => {
if (!open && triggerRef?.current) {
setTimeout(() => {
@ -71,6 +71,7 @@ const DialogContent = React.forwardRef<
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}

View file

@ -1,2 +1,2 @@
// export * from './queries';
export * from './queries';
export * from './mutations';

View file

@ -0,0 +1,42 @@
import { useLocation } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import { logger } from '~/utils';
export const useGetMessagesByConvoId = <TData = t.TMessage[]>(
id: string,
config?: UseQueryOptions<t.TMessage[], unknown, TData>,
): QueryObserverResult<TData> => {
const location = useLocation();
const queryClient = useQueryClient();
return useQuery<t.TMessage[], unknown, TData>(
[QueryKeys.messages, id],
async () => {
const result = await dataService.getMessagesByConvoId(id);
if (!location.pathname.includes('/c/new') && result?.length === 1) {
const currentMessages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, id]);
if (currentMessages?.length === 1) {
return result;
}
if (currentMessages && currentMessages?.length > 1) {
logger.warn(
'messages',
`Messages query for convo ${id} returned fewer than cache; path: "${location.pathname}"`,
result,
currentMessages,
);
return currentMessages;
}
}
return result;
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};

View file

@ -6,6 +6,7 @@ import {
ContentTypes,
EModelEndpoint,
parseCompactConvo,
replaceSpecialVars,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import { useSetRecoilState, useResetRecoilState, useRecoilValue } from 'recoil';
@ -26,6 +27,7 @@ import { getArtifactsMode } from '~/utils/artifacts';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
import { useNavigate } from 'react-router-dom';
import { useAuthContext } from '~/hooks';
const logChatRequest = (request: Record<string, unknown>) => {
logger.log('=====================================\nAsk function called with:');
@ -66,19 +68,19 @@ export default function useChatFunctions({
setSubmission: SetterOrUpdater<TSubmission | null>;
setLatestMessage?: SetterOrUpdater<TMessage | null>;
}) {
const navigate = useNavigate();
const getSender = useGetSender();
const { user } = useAuthContext();
const queryClient = useQueryClient();
const setFilesToDelete = useSetFilesToDelete();
const getEphemeralAgent = useGetEphemeralAgent();
const isTemporary = useRecoilValue(store.isTemporary);
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const includeShadcnui = useRecoilValue(store.includeShadcnui);
const customPromptMode = useRecoilValue(store.customPromptMode);
const navigate = useNavigate();
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const setFilesToDelete = useSetFilesToDelete();
const getSender = useGetSender();
const isTemporary = useRecoilValue(store.isTemporary);
const queryClient = useQueryClient();
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
const customPromptMode = useRecoilValue(store.customPromptMode);
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
const ask: TAskFunction = (
{
@ -128,6 +130,13 @@ export default function useChatFunctions({
let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? [];
if (conversation?.promptPrefix) {
conversation.promptPrefix = replaceSpecialVars({
text: conversation.promptPrefix,
user,
});
}
// construct the query message
// this is not a real messageId, it is used as placeholder before real messageId returned
text = text.trim();

View file

@ -1,10 +1,10 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import type { TMessage } from 'librechat-data-provider';
import useChatFunctions from '~/hooks/Chat/useChatFunctions';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import useNewConvo from '~/hooks/useNewConvo';
import store from '~/store';

View file

@ -96,13 +96,14 @@ export default function useTextarea({
return localize('com_endpoint_message_not_appendable');
}
const sender = isAssistant || isAgent
? getEntityName({ name: entityName, isAgent, localize })
: getSender(conversation as TEndpointOption);
const sender =
isAssistant || isAgent
? getEntityName({ name: entityName, isAgent, localize })
: getSender(conversation as TEndpointOption);
return `${localize(
'com_endpoint_message_new', { 0: sender ? sender : localize('com_endpoint_ai') },
)}`;
return `${localize('com_endpoint_message_new', {
0: sender ? sender : localize('com_endpoint_ai'),
})}`;
};
const placeholder = getPlaceholderText();
@ -237,7 +238,8 @@ export default function useTextarea({
textAreaRef,
handlePaste,
handleKeyDown,
handleCompositionStart,
isNotAppendable,
handleCompositionEnd,
handleCompositionStart,
};
}

View file

@ -1,10 +1,9 @@
import { v4 } from 'uuid';
import { useCallback } from 'react';
import { Constants } from 'librechat-data-provider';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Constants, replaceSpecialVars } from 'librechat-data-provider';
import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers';
import { useAuthContext } from '~/hooks/AuthContext';
import { replaceSpecialVars } from '~/utils';
import store from '~/store';
const appendIndex = (index: number, value?: string) => {

View file

@ -20,12 +20,13 @@ import type { TGenTitleMutation } from '~/data-provider';
import type { SetterOrUpdater, Resetter } from 'recoil';
import type { ConversationCursorData } from '~/utils';
import {
logger,
scrollToEnd,
getAllContentText,
addConvoToAllQueries,
updateConvoInAllQueries,
removeConvoFromAllQueries,
findConversationInInfinite,
getAllContentText,
} from '~/utils';
import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler';
import useContentHandler from '~/hooks/SSE/useContentHandler';
@ -487,10 +488,6 @@ export default function useEventHandlers({
}
if (setConversation && isAddedRequest !== true) {
if (location.pathname === '/c/new') {
navigate(`/c/${conversation.conversationId}`, { replace: true });
}
setConversation((prevState) => {
const update = {
...prevState,
@ -508,6 +505,9 @@ export default function useEventHandlers({
}
return update;
});
if (location.pathname === '/c/new') {
navigate(`/c/${conversation.conversationId}`, { replace: true });
}
}
setIsSubmitting(false);
@ -536,6 +536,12 @@ export default function useEventHandlers({
const conversationId =
userMessage.conversationId ?? submission.conversation?.conversationId ?? '';
const setErrorMessages = (convoId: string, errorMessage: TMessage) => {
const finalMessages: TMessage[] = [...messages, userMessage, errorMessage];
setMessages(finalMessages);
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, convoId], finalMessages);
};
const parseErrorResponse = (data: TResData | Partial<TMessage>) => {
const metadata = data['responseMessage'] ?? data;
const errorMessage: Partial<TMessage> = {
@ -553,7 +559,7 @@ export default function useEventHandlers({
};
if (!data) {
const convoId = conversationId || v4();
const convoId = conversationId || `_${v4()}`;
const errorMetadata = parseErrorResponse({
text: 'Error connecting to server, try refreshing the page.',
...submission,
@ -564,7 +570,7 @@ export default function useEventHandlers({
getMessages,
submission,
});
setMessages([...messages, userMessage, errorResponse]);
setErrorMessages(convoId, errorResponse);
if (newConversation) {
newConversation({
template: { conversationId: convoId },
@ -577,9 +583,9 @@ export default function useEventHandlers({
const receivedConvoId = data.conversationId ?? '';
if (!conversationId && !receivedConvoId) {
const convoId = v4();
const convoId = `_${v4()}`;
const errorResponse = parseErrorResponse(data);
setMessages([...messages, userMessage, errorResponse]);
setErrorMessages(convoId, errorResponse);
if (newConversation) {
newConversation({
template: { conversationId: convoId },
@ -590,7 +596,7 @@ export default function useEventHandlers({
return;
} else if (!receivedConvoId) {
const errorResponse = parseErrorResponse(data);
setMessages([...messages, userMessage, errorResponse]);
setErrorMessages(conversationId, errorResponse);
setIsSubmitting(false);
return;
}
@ -601,7 +607,7 @@ export default function useEventHandlers({
parentMessageId: userMessage.messageId,
});
setMessages([...messages, userMessage, errorResponse]);
setErrorMessages(receivedConvoId, errorResponse);
if (receivedConvoId && paramId === Constants.NEW_CONVO && newConversation) {
newConversation({
template: { conversationId: receivedConvoId },
@ -612,7 +618,15 @@ export default function useEventHandlers({
setIsSubmitting(false);
return;
},
[setCompleted, setMessages, paramId, newConversation, setIsSubmitting, getMessages],
[
setCompleted,
setMessages,
paramId,
newConversation,
setIsSubmitting,
getMessages,
queryClient,
],
);
const abortConversation = useCallback(
@ -649,9 +663,11 @@ export default function useEventHandlers({
);
return;
} else if (!isAssistantsEndpoint(endpoint)) {
const convoId = conversationId || `_${v4()}`;
logger.log('conversation', 'Aborted conversation with minimal messages, ID: ' + convoId);
if (newConversation) {
newConversation({
template: { conversationId: conversationId || v4() },
template: { conversationId: convoId },
preset: tPresetSchema.parse(submission.conversation),
});
}

View file

@ -151,12 +151,28 @@ const useNewConvo = (index = 0) => {
if (!(keepAddedConvos ?? false)) {
clearAllConversations(true);
}
logger.log('conversation', 'Setting conversation from `useNewConvo`', conversation);
setConversation(conversation);
const isCancelled = conversation.conversationId?.startsWith('_');
if (isCancelled) {
logger.log(
'conversation',
'Cancelled conversation, setting to `new` in `useNewConvo`',
conversation,
);
setConversation({
...conversation,
conversationId: 'new',
});
} else {
logger.log('conversation', 'Setting conversation from `useNewConvo`', conversation);
setConversation(conversation);
}
setSubmission({} as TSubmission);
if (!(keepLatestMessage ?? false)) {
clearAllLatestMessages();
}
if (isCancelled) {
return;
}
const searchParamsString = searchParams?.toString();
const getParams = () => (searchParamsString ? `?${searchParamsString}` : '');

View file

@ -542,6 +542,7 @@
"com_ui_bulk_delete_error": "Failed to delete shared links",
"com_ui_callback_url": "Callback URL",
"com_ui_cancel": "Cancel",
"com_ui_category": "Category",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
"com_ui_clear": "Clear",
@ -616,7 +617,7 @@
"com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device",
"com_ui_download_error": "Error downloading file. The file may have been deleted.",
"com_ui_drag_drop": "something needs to go here. was empty",
"com_ui_dropdown_variables": "Dropdown variables:",
"com_ui_dropdown_variables": "Dropdown variables",
"com_ui_dropdown_variables_info": "Create custom dropdown menus for your prompts: `{{variable_name:option1|option2|option3}}`",
"com_ui_duplicate": "Duplicate",
"com_ui_duplication_error": "There was an error duplicating the conversation",
@ -806,9 +807,14 @@
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
"com_ui_simple": "Simple",
"com_ui_size": "Size",
"com_ui_special_variables": "Special variables:",
"com_ui_special_variables_info": "Use `{{current_date}}` for the current date, and `{{current_user}}` for your given account name.",
"com_ui_special_var_current_date": "Current Date",
"com_ui_special_var_current_datetime": "Current Date & Time",
"com_ui_special_var_iso_datetime": "UTC ISO Datetime",
"com_ui_special_var_current_user": "Current User",
"com_ui_special_variables": "Special variables",
"com_ui_special_variables_more_info": "You can select special variables from the dropdown: `{{current_date}}` (today's date and day of week), `{{current_datetime}}` (local date and time), `{{utc_iso_datetime}}` (UTC ISO datetime), and `{{current_user}}` (your account name).",
"com_ui_speech_while_submitting": "Can't submit speech while a response is being generated",
"com_ui_sr_actions_menu": "Open actions menu for \"{{0}}\"",
"com_ui_stop": "Stop",
"com_ui_storage": "Storage",
"com_ui_submit": "Submit",
@ -859,4 +865,4 @@
"com_ui_zoom": "Zoom",
"com_user_message": "You",
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
}
}

View file

@ -1,29 +1,18 @@
import { format } from 'date-fns';
import type { TUser, TPromptGroup } from 'librechat-data-provider';
export function replaceSpecialVars({ text, user }: { text: string; user?: TUser }) {
if (!text) {
return text;
}
const currentDate = format(new Date(), 'yyyy-MM-dd');
text = text.replace(/{{current_date}}/gi, currentDate);
if (!user) {
return text;
}
const currentUser = user.name;
text = text.replace(/{{current_user}}/gi, currentUser);
return text;
}
import { specialVariables } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
/**
* Detects the presence of variables in the given text, excluding {{current_date}} and {{current_user}}.
* Detects the presence of variables in the given text, excluding those found in `specialVariables`.
*/
export const detectVariables = (text: string): boolean => {
const regex = /{{(?!current_date|current_user)[^{}]{1,}}}/gi;
return regex.test(text);
// Extract all variables with a simple regex
const allVariablesRegex = /{{([^{}]+?)}}/gi;
const matches = Array.from(text.matchAll(allVariablesRegex)).map((match) =>
match[1].trim().toLowerCase(),
);
// Check if any non-special variables exist
return matches.some((variable) => !specialVariables[variable]);
};
export const wrapVariable = (variable: string) => `{{${variable}}}`;
@ -94,11 +83,14 @@ export function formatDateTime(dateTimeString: string) {
}
export const mapPromptGroups = (groups: TPromptGroup[]): Record<string, TPromptGroup> => {
return groups.reduce((acc, group) => {
if (!group._id) {
return groups.reduce(
(acc, group) => {
if (!group._id) {
return acc;
}
acc[group._id] = group;
return acc;
}
acc[group._id] = group;
return acc;
}, {} as Record<string, TPromptGroup>);
},
{} as Record<string, TPromptGroup>,
);
};