🧪 feat: Prompt Dropdown Variable; style: Add Markdown Support (#3681)

* feat: Add extended inputs for promts library variables

* feat: Add maxRows prop to VariableForm input field

* 📩 feat: invite user (#3012)

* feat: basic invite-user script

* feat: add invite user functionality and registration validation middleware

* fix: invite user fixes

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

* style(Registration): add spinner to continue button

* refactor: import ordrer

* feat: improve invite user script and error handling

* fix: merge conflict

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

* fix: token operation and checkinvite issues

* bring back comment and remove console log

* fix: return invalid token when token is not found

* fix: getInvite fix

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

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

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

* Update AuthService.js

---------

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

* style: improve OpenAI.tsx input field focus styling

* refactor: update import statement in Input.tsx

* refactor: remove multi-line

* refactor:  update placeholder text to use localization

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

* Add ReactMarkdown

* chore: styling, import order

* refactor: update ReactMarkdown usage in VariableForm

* style: remove markdown class

* refactor: update mobile styling and use code renderer

* style(InputWithDropDown): update focus trigger style

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

---------

Co-authored-by: Konstantin Meshcheryakov <kmeshcheryakov@klika-tech.com>
Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Co-authored-by: bsu3338 <bsu3338@users.noreply.github.com>
This commit is contained in:
Danny Avila 2024-08-18 05:52:05 -04:00 committed by GitHub
parent bbb9324447
commit d3a20357e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 473 additions and 110 deletions

View file

@ -66,7 +66,7 @@ export default function OptionsPopover({
{presetsDisabled ? null : ( {presetsDisabled ? null : (
<Button <Button
type="button" type="button"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-ring-primary dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white" className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-normal text-black hover:bg-gray-100 hover:text-black focus-visible:ring-1 focus-visible:ring-ring-primary dark:border-gray-600 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus-visible:ring-white"
onClick={saveAsPreset} onClick={saveAsPreset}
> >
<Save className="mr-1 w-[14px]" /> <Save className="mr-1 w-[14px]" />

View file

@ -8,7 +8,7 @@ import { useRecoilValue } from 'recoil';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import type { PluggableList } from 'unified'; import type { PluggableList } from 'unified';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import { cn, langSubset, validateIframe, processLaTeX } from '~/utils'; import { cn, langSubset, validateIframe, processLaTeX, handleDoubleClick } from '~/utils';
import CodeBlock from '~/components/Messages/Content/CodeBlock'; import CodeBlock from '~/components/Messages/Content/CodeBlock';
import { useFileDownload } from '~/data-provider'; import { useFileDownload } from '~/data-provider';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
@ -21,12 +21,16 @@ type TCodeProps = {
children: React.ReactNode; children: React.ReactNode;
}; };
export const code = memo(({ inline, className, children }: TCodeProps) => { export const code: React.ElementType = memo(({ inline, className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className ?? ''); const match = /language-(\w+)/.exec(className ?? '');
const lang = match && match[1]; const lang = match && match[1];
if (inline) { if (inline) {
return <code className={className}>{children}</code>; return (
<code onDoubleClick={handleDoubleClick} className={className}>
{children}
</code>
);
} else { } else {
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} />; return <CodeBlock lang={lang ?? 'text'} codeChildren={children} />;
} }

View file

@ -144,7 +144,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
placeholder={localize('com_endpoint_openai_prompt_prefix_placeholder')} placeholder={localize('com_endpoint_openai_prompt_prefix_placeholder')}
className={cn( className={cn(
defaultTextProps, defaultTextProps,
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ', 'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 transition-colors focus:outline-none',
)} )}
/> />
</div> </div>

View file

@ -24,14 +24,18 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plug
return ( return (
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700"> <div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span> <span className="">{lang}</span>
{plugin ? ( {plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" /> <InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
) : ( ) : (
<button <button
className={cn('ml-auto flex gap-2', error ? 'h-4 w-4 items-start text-white/50' : '')} type="button"
className={cn(
'ml-auto flex gap-2',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={async () => { onClick={async () => {
const codeString = codeRef.current?.textContent; const codeString = codeRef.current?.textContent;
if (codeString) { if (codeString != null) {
setIsCopied(true); setIsCopied(true);
copy(codeString, { format: 'text/plain' }); copy(codeString, { format: 'text/plain' });
@ -44,12 +48,12 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plug
{isCopied ? ( {isCopied ? (
<> <>
<CheckMark className="h-[18px] w-[18px]" /> <CheckMark className="h-[18px] w-[18px]" />
{error ? '' : localize('com_ui_copied')} {error === true ? '' : localize('com_ui_copied')}
</> </>
) : ( ) : (
<> <>
<Clipboard /> <Clipboard />
{error ? '' : localize('com_ui_copy_code')} {error === true ? '' : localize('com_ui_copy_code')}
</> </>
)} )}
</button> </button>

View file

@ -38,7 +38,7 @@ const Command = ({
} }
}; };
if (disabled && !command) { if (disabled === true && !command) {
return null; return null;
} }
@ -53,10 +53,10 @@ const Command = ({
placeholder={localize('com_ui_command_placeholder')} placeholder={localize('com_ui_command_placeholder')}
value={command} value={command}
onChange={handleInputChange} 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" className="w-full rounded-lg border-none bg-surface-tertiary p-1 text-text-primary placeholder:text-text-secondary focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary md:w-96"
/> />
{!disabled && ( {disabled !== true && (
<span className="mr-1 w-10 text-xs text-text-tertiary md:text-sm">{`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`}</span> <span className="mr-1 w-10 text-xs text-text-secondary md:text-sm">{`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`}</span>
)} )}
</h3> </h3>
</div> </div>

View file

@ -50,10 +50,10 @@ const Description = ({
placeholder={localize('com_ui_description_placeholder')} placeholder={localize('com_ui_description_placeholder')}
value={description} value={description}
onChange={handleInputChange} 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" className="w-full rounded-lg border-none bg-surface-tertiary p-1 text-text-primary placeholder:text-text-secondary focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary md:w-96"
/> />
{!disabled && ( {!disabled && (
<span className="mr-1 w-10 text-xs text-text-tertiary md:text-sm">{`${charCount}/${MAX_LENGTH}`}</span> <span className="mr-1 w-10 text-xs text-text-secondary md:text-sm">{`${charCount}/${MAX_LENGTH}`}</span>
)} )}
</h3> </h3>
</div> </div>

View file

@ -111,10 +111,9 @@ const CreatePromptForm = ({
<Input <Input
{...field} {...field}
type="text" type="text"
className="mr-2 w-full border border-gray-300 p-2 text-2xl dark:border-gray-600" className="mr-2 w-full border border-border-medium p-2 text-2xl placeholder:text-text-tertiary dark:placeholder:text-text-secondary"
placeholder={`${localize('com_ui_prompt_name')}*`} placeholder={`${localize('com_ui_prompt_name')}*`}
tabIndex={1} tabIndex={0}
autoFocus={true}
/> />
<div <div
className={cn( className={cn(
@ -127,15 +126,15 @@ const CreatePromptForm = ({
</div> </div>
)} )}
/> />
<CategorySelector tabIndex={5} /> <CategorySelector tabIndex={0} />
</div> </div>
</div> </div>
<div className="flex w-full flex-col gap-4 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-border-medium py-2 pl-4 pr-1 text-base font-semibold dark:text-gray-200">
{localize('com_ui_prompt_text')}* {localize('com_ui_prompt_text')}*
</h2> </h2>
<div className="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-border-medium p-4 transition-all duration-150">
<Controller <Controller
name="prompt" name="prompt"
control={control} control={control}
@ -144,9 +143,9 @@ const CreatePromptForm = ({
<div> <div>
<TextareaAutosize <TextareaAutosize
{...field} {...field}
className="w-full rounded border border-gray-300 px-2 py-1 focus:outline-none dark:border-gray-600 dark:bg-transparent dark:text-gray-200" className="w-full rounded border border-border-medium px-2 py-1 focus:outline-none dark:bg-transparent dark:text-gray-200"
minRows={6} minRows={6}
tabIndex={2} tabIndex={0}
/> />
<div <div
className={`mt-1 text-sm text-red-500 ${ className={`mt-1 text-sm text-red-500 ${
@ -163,16 +162,11 @@ const CreatePromptForm = ({
<PromptVariables promptText={promptText} /> <PromptVariables promptText={promptText} />
<Description <Description
onValueChange={(value) => methods.setValue('oneliner', value)} onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={3} tabIndex={0}
/> />
<Command onValueChange={(value) => methods.setValue('command', value)} tabIndex={4} /> <Command onValueChange={(value) => methods.setValue('command', value)} tabIndex={0} />
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<Button <Button tabIndex={0} type="submit" disabled={!isDirty || isSubmitting || !isValid}>
tabIndex={6}
type="submit"
variant="default"
disabled={!isDirty || isSubmitting || !isValid}
>
{localize('com_ui_create_prompt')} {localize('com_ui_create_prompt')}
</Button> </Button>
</div> </div>

View file

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

View file

@ -1,12 +1,64 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form'; import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
import type { TPromptGroup } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider';
import { extractVariableInfo, wrapVariable, replaceSpecialVars } from '~/utils'; import {
cn,
wrapVariable,
defaultTextProps,
replaceSpecialVars,
extractVariableInfo,
} from '~/utils';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks'; import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { Textarea } from '~/components/ui'; import { TextareaAutosize, InputWithDropdown } from '~/components/ui';
import { code } from '~/components/Chat/Messages/Content/Markdown';
type FieldType = 'text' | 'select';
type FieldConfig = {
variable: string;
type: FieldType;
options?: string[];
};
type FormValues = { type FormValues = {
fields: { variable: string; value: string }[]; fields: { variable: string; value: string; config: FieldConfig }[];
};
/**
* Variable Format Guide:
*
* Variables in prompts should be enclosed in double curly braces: {{variable}}
*
* Simple text input:
* {{variable_name}}
*
* Dropdown select with predefined options:
* {{variable_name:option1|option2|option3}}
*
* All dropdown selects allow custom input in addition to predefined options.
*
* Examples:
* {{name}} - Simple text input for a name
* {{tone:formal|casual|business casual}} - Dropdown for tone selection with custom input option
*
* Note: The order of variables in the prompt will be preserved in the input form.
*/
const parseFieldConfig = (variable: string): FieldConfig => {
const content = variable;
if (content.includes(':')) {
const [name, options] = content.split(':');
if (options && options.includes('|')) {
return { variable: name, type: 'select', options: options.split('|') };
}
}
return { variable: content, type: 'text' };
}; };
export default function VariableForm({ export default function VariableForm({
@ -32,7 +84,11 @@ export default function VariableForm({
const { submitPrompt } = useSubmitMessage(); const { submitPrompt } = useSubmitMessage();
const { control, handleSubmit } = useForm<FormValues>({ const { control, handleSubmit } = useForm<FormValues>({
defaultValues: { defaultValues: {
fields: uniqueVariables.map((variable) => ({ variable: wrapVariable(variable), value: '' })), fields: uniqueVariables.map((variable) => ({
variable: wrapVariable(variable),
value: '',
config: parseFieldConfig(variable),
})),
}, },
}); });
@ -50,31 +106,16 @@ export default function VariableForm({
return null; return null;
} }
const generateHighlightedText = () => { const generateHighlightedMarkdown = () => {
let tempText = mainText; let tempText = mainText;
const parts: JSX.Element[] = []; allVariables.forEach((variable) => {
allVariables.forEach((variable, index) => {
const placeholder = `{{${variable}}}`; const placeholder = `{{${variable}}}`;
const partsBeforePlaceholder = tempText.split(placeholder);
const fieldIndex = variableIndexMap.get(variable) as string | number; const fieldIndex = variableIndexMap.get(variable) as string | number;
const fieldValue = fieldValues[fieldIndex].value as string; const fieldValue = fieldValues[fieldIndex].value as string;
parts.push( const highlightText = fieldValue !== '' ? fieldValue : placeholder;
<span key={`before-${index}`}>{partsBeforePlaceholder[0]}</span>, tempText = tempText.replaceAll(placeholder, `**${highlightText}**`);
<span
key={`highlight-${index}`}
className="rounded bg-yellow-100 p-1 font-medium dark:text-gray-800"
>
{fieldValue !== '' ? fieldValue : placeholder}
</span>,
);
tempText = partsBeforePlaceholder.slice(1).join(placeholder);
}); });
return tempText;
parts.push(<span key="last-part">{tempText}</span>);
return parts;
}; };
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {
@ -91,32 +132,53 @@ export default function VariableForm({
}; };
return ( return (
<div className="container mx-auto p-1"> <div className="mx-auto p-1 md:container">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6 max-h-screen overflow-auto rounded-md bg-gray-100 p-4 dark:bg-gray-700/50 dark:text-gray-300 md:max-h-80"> <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">
<p className="text-md whitespace-pre-wrap">{generateHighlightedText()}</p> <ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={[
[rehypeKatex, { output: 'mathml' }],
[rehypeHighlight, { ignoreMissing: true }],
]}
components={{ code }}
className="prose dark:prose-invert light dark:text-gray-70 my-1 max-h-[50vh] break-words"
>
{generateHighlightedMarkdown()}
</ReactMarkdown>
</div> </div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4"> <div className="space-y-4">
{fields.map((field, index) => ( {fields.map((field, index) => (
<div key={field.id} className="flex flex-col"> <div key={field.id} className="flex flex-col space-y-2">
<Controller <Controller
name={`fields.${index}.value`} name={`fields.${index}.value`}
control={control} control={control}
render={({ field }) => ( render={({ field: inputField }) => {
<Textarea if (field.config.type === 'select') {
{...field} return (
id={`fields.${index}.value`} <InputWithDropdown
className="input text-grey-darker h-10 rounded border px-3 py-2 focus:bg-white dark:border-gray-500 dark:focus:bg-gray-700" {...inputField}
placeholder={uniqueVariables[index]} id={`fields.${index}.value`}
onKeyDown={(e) => { className={cn(defaultTextProps, 'focus:bg-surface-tertiary')}
// Submit the form on enter like you would with an Input component placeholder={localize('com_ui_enter_var', field.config.variable)}
if (e.key === 'Enter' && !e.shiftKey) { options={field.config.options || []}
e.preventDefault(); />
handleSubmit((data) => onSubmit(data))(); );
} }
}}
/> return (
)} <TextareaAutosize
{...inputField}
id={`fields.${index}.value`}
className={cn(
defaultTextProps,
'rounded px-3 py-2 focus:bg-surface-tertiary',
)}
placeholder={localize('com_ui_enter_var', field.config.variable)}
maxRows={8}
/>
);
}}
/> />
</div> </div>
))} ))}
@ -124,7 +186,7 @@ export default function VariableForm({
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="submit" type="submit"
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600" className="btn rounded bg-green-500 px-4 py-2 font-bold text-white transition-all hover:bg-green-600"
> >
{localize('com_ui_submit')} {localize('com_ui_submit')}
</button> </button>

View file

@ -0,0 +1,42 @@
import React from 'react';
import { handleDoubleClick } from '~/utils';
export const CodeVariableGfm = ({ children }: { children: React.ReactNode }) => {
return (
<code
onDoubleClick={handleDoubleClick}
className="rounded-md bg-surface-primary-alt p-1 text-xs text-text-secondary md:text-sm"
>
{children}
</code>
);
};
const regex = /{{(.*?)}}/g;
export const PromptVariableGfm = ({
children,
}: {
children: React.ReactNode & React.ReactNode[];
}) => {
const renderContent = (child: React.ReactNode) => {
if (typeof child === 'object' && child !== null) {
return child;
}
if (typeof child !== 'string') {
return child;
}
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">
{`{{${part}}}`}
</b>
) : (
part
),
);
};
return <p>{React.Children.map(children, (child) => renderContent(child))}</p>;
};

View file

@ -13,7 +13,7 @@ const PreviewPrompt = ({
}) => { }) => {
return ( return (
<OGDialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="max-w-3xl bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300"> <OGDialogContent className="max-w-full bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-3xl">
<div className="p-2"> <div className="p-2">
<PromptDetails group={group} /> <PromptDetails group={group} />
</div> </div>

View file

@ -1,18 +1,33 @@
import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import rehypeHighlight from 'rehype-highlight';
import type { TPromptGroup } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider';
import { code } from '~/components/Chat/Messages/Content/Markdown';
import { useLocalize, useAuthContext } from '~/hooks';
import CategoryIcon from './Groups/CategoryIcon'; import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables'; import PromptVariables from './PromptVariables';
import { PromptVariableGfm } from './Markdown';
import { replaceSpecialVars } from '~/utils';
import Description from './Description'; import Description from './Description';
import { useLocalize } from '~/hooks';
import Command from './Command'; import Command from './Command';
const PromptDetails = ({ group }: { group: TPromptGroup }) => { const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
const localize = useLocalize(); const localize = useLocalize();
const { user } = useAuthContext();
const mainText = useMemo(() => {
const initialText = group?.productionPrompt?.prompt ?? '';
return replaceSpecialVars({ text: initialText, user });
}, [group?.productionPrompt?.prompt, user]);
if (!group) { if (!group) {
return null; return null;
} }
const promptText = group.productionPrompt?.prompt ?? '';
return ( return (
<div> <div>
<div className="flex flex-col items-center justify-between px-4 dark:text-gray-200 sm:flex-row"> <div className="flex flex-col items-center justify-between px-4 dark:text-gray-200 sm:flex-row">
@ -27,17 +42,27 @@ const PromptDetails = ({ group }: { group: TPromptGroup }) => {
</div> </div>
</div> </div>
</div> </div>
<div className="flex h-full w-full flex-col md:flex-row"> <div className="flex h-full max-h-screen w-full max-w-[90vw] flex-col overflow-y-auto md:flex-row">
<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 className="flex flex-1 flex-col gap-4 border-gray-300 p-0 dark:border-gray-600 md:max-h-[calc(100vh-150px)] md:p-2">
<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 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 sm:max-w-full">
<span className="block break-words px-2 py-1 dark:text-gray-200">{promptText}</span> <ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={[
[rehypeKatex, { output: 'mathml' }],
[rehypeHighlight, { ignoreMissing: true }],
]}
components={{ p: PromptVariableGfm, code }}
className="prose dark:prose-invert light dark:text-gray-70 my-1"
>
{mainText}
</ReactMarkdown>
</div> </div>
</div> </div>
<PromptVariables promptText={promptText} /> <PromptVariables promptText={mainText} showInfo={false} />
<Description initialValue={group.oneliner} disabled={true} /> <Description initialValue={group.oneliner} disabled={true} />
<Command initialValue={group.command} disabled={true} /> <Command initialValue={group.command} disabled={true} />
</div> </div>

View file

@ -1,13 +1,22 @@
import { useMemo, memo } from 'react'; import { useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { EditIcon } from 'lucide-react'; import { EditIcon } from 'lucide-react';
import type { PluggableList } from 'unified';
import rehypeHighlight from 'rehype-highlight';
import { Controller, useFormContext, useFormState } from 'react-hook-form'; import { Controller, useFormContext, useFormState } from 'react-hook-form';
import remarkGfm from 'remark-gfm';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import ReactMarkdown from 'react-markdown';
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd'; import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
import { code } from '~/components/Chat/Messages/Content/Markdown';
import { SaveIcon, CrossIcon } from '~/components/svg'; import { SaveIcon, CrossIcon } from '~/components/svg';
import { TextareaAutosize } from '~/components/ui'; import { TextareaAutosize } from '~/components/ui';
import { PromptVariableGfm } from './Markdown';
import { PromptsEditorMode } from '~/common'; import { PromptsEditorMode } from '~/common';
import { cn, langSubset } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
const { promptsEditorMode } = store; const { promptsEditorMode } = store;
@ -32,6 +41,18 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
return isEditing ? SaveIcon : EditIcon; return isEditing ? SaveIcon : EditIcon;
}, [isEditing, prompt]); }, [isEditing, prompt]);
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
return ( return (
<div> <div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-medium py-2 pl-4 text-base font-semibold text-text-primary"> <h2 className="flex items-center justify-between rounded-t-lg border border-border-medium py-2 pl-4 text-base font-semibold text-text-primary">
@ -44,7 +65,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
<EditorIcon <EditorIcon
className={cn( className={cn(
'icon-lg', 'icon-lg',
isEditing ? 'p-[0.05rem]' : 'text-gray-400 hover:text-gray-600', isEditing ? 'p-[0.05rem]' : 'text-secondary-alt hover:text-text-primary',
)} )}
/> />
</button> </button>
@ -54,7 +75,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
role="button" role="button"
className={cn( className={cn(
'min-h-[8rem] w-full rounded-b-lg border border-border-medium p-4 transition-all duration-150', 'min-h-[8rem] w-full rounded-b-lg border border-border-medium p-4 transition-all duration-150',
{ 'cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-100/10': !isEditing }, { 'bg-surface-secondary-alt cursor-pointer hover:bg-surface-tertiary': !isEditing },
)} )}
onClick={() => !isEditing && setIsEditing(true)} onClick={() => !isEditing && setIsEditing(true)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -85,9 +106,14 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
}} }}
/> />
) : ( ) : (
<pre className="block h-full w-full whitespace-pre-wrap break-words px-2 py-1 text-left text-text-primary"> <ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins}
components={{ p: PromptVariableGfm, code }}
className="markdown prose dark:prose-invert light my-1 w-full break-words text-text-primary"
>
{field.value} {field.value}
</pre> </ReactMarkdown>
) )
} }
/> />

View file

@ -1,6 +1,8 @@
import { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Variable } from 'lucide-react'; import { Variable } from 'lucide-react';
import { extractUniqueVariables, cn } from '~/utils'; import ReactMarkdown from 'react-markdown';
import { cn, extractUniqueVariables } from '~/utils';
import { CodeVariableGfm } from './Markdown';
import { Separator } from '~/components/ui'; import { Separator } from '~/components/ui';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
@ -12,7 +14,13 @@ const specialVariables = {
const specialVariableClasses = const specialVariableClasses =
'bg-yellow-500/25 text-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90'; 'bg-yellow-500/25 text-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
const PromptVariables = ({ promptText }: { promptText: string }) => { const PromptVariables = ({
promptText,
showInfo = true,
}: {
promptText: string;
showInfo?: boolean;
}) => {
const localize = useLocalize(); const localize = useLocalize();
const variables = useMemo(() => { const variables = useMemo(() => {
@ -32,25 +40,52 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
<label <label
className={cn( className={cn(
'mr-1 rounded-full border border-border-medium px-2 text-text-secondary', 'mr-1 rounded-full border border-border-medium px-2 text-text-secondary',
specialVariables[variable.toLowerCase()] ? specialVariableClasses : '', specialVariables[variable.toLowerCase()] != null ? specialVariableClasses : '',
)} )}
key={index} key={index}
> >
{specialVariables[variable.toLowerCase()] ? variable.toLowerCase() : variable} {specialVariables[variable.toLowerCase()] != null
? variable.toLowerCase()
: variable}
</label> </label>
))} ))}
</div> </div>
) : ( ) : (
<div className="flex h-7 items-center"> <div className="flex h-7 items-center">
<span className="text-xs text-text-tertiary md:text-sm"> <span className="text-xs text-text-secondary md:text-sm">
{localize('com_ui_variables_info')} <ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_variables_info')}
</ReactMarkdown>
</span> </span>
</div> </div>
)} )}
<Separator className="my-3 bg-border-medium" /> <Separator className="my-3 bg-border-medium" />
<span className="text-xs text-text-tertiary md:text-sm"> {showInfo && (
{localize('com_ui_special_variables')} <div className="flex flex-col space-y-4">
</span> <div>
<span className="text-xs font-medium text-text-secondary md:text-sm">
{localize('com_ui_special_variables')}
</span>
{'\u00A0'}
<span className="text-xs text-text-secondary md:text-sm">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_special_variables_info')}
</ReactMarkdown>
</span>
</div>
<div>
<span className="text-xs font-medium text-text-secondary md:text-sm">
{localize('com_ui_dropdown_variables')}
</span>
{'\u00A0'}
<span className="text-xs text-text-secondary md:text-sm">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_dropdown_variables_info')}
</ReactMarkdown>
</span>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '../../utils'; import { cn } from '~/utils';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>; export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;

View file

@ -0,0 +1,154 @@
import * as React from 'react';
import { Input } from '~/components/ui/Input';
import { cn } from '~/utils';
export type InputWithDropdownProps = React.InputHTMLAttributes<HTMLInputElement> & {
options: string[];
onSelect?: (value: string) => void;
};
const InputWithDropdown = React.forwardRef<HTMLInputElement, InputWithDropdownProps>(
({ className, options, onSelect, ...props }, ref) => {
const [isOpen, setIsOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState((props.value as string) || '');
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleSelect = (value: string) => {
setInputValue(value);
setIsOpen(false);
setHighlightedIndex(-1);
if (onSelect) {
onSelect(value);
}
if (props.onChange) {
props.onChange({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
if (props.onChange) {
props.onChange(e);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prevIndex) =>
prevIndex < options.length - 1 ? prevIndex + 1 : prevIndex,
);
}
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0));
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlightedIndex !== -1) {
handleSelect(options[highlightedIndex]);
}
setIsOpen(false);
break;
case 'Escape':
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
};
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
setIsOpen(false);
setHighlightedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="relative" ref={inputRef}>
<div className="relative">
<Input
{...props}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-controls="dropdown-list"
className={cn('bg-surface-secondary', className ?? '')}
ref={ref}
/>
<button
type="button"
className="text-tertiary hover:text-secondary absolute inset-y-0 right-0 flex items-center rounded-md px-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring-primary"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? 'Close dropdown' : 'Open dropdown'}
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
{isOpen && (
<ul
id="dropdown-list"
role="listbox"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border-medium bg-surface-secondary shadow-lg focus:ring-1 focus:ring-inset focus:ring-ring-primary"
>
{options.map((option, index) => (
<li
key={index}
role="option"
aria-selected={index === highlightedIndex}
className={cn(
'cursor-pointer rounded-md px-3 py-2',
'focus:bg-surface-tertiary focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring-primary',
index === highlightedIndex
? 'text-primary bg-surface-active'
: 'text-secondary hover:bg-surface-tertiary',
)}
onClick={() => handleSelect(option)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(option);
}
}}
tabIndex={0}
>
{option}
</li>
))}
</ul>
)}
</div>
);
},
);
InputWithDropdown.displayName = 'InputWithDropdown';
export default InputWithDropdown;

View file

@ -25,10 +25,11 @@ export * from './Tooltip';
export { default as Combobox } from './Combobox'; export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown'; export { default as Dropdown } from './Dropdown';
export { default as FileUpload } from './FileUpload'; export { default as FileUpload } from './FileUpload';
export { default as DropdownPopup } from './DropdownPopup';
export { default as DelayedRender } from './DelayedRender'; export { default as DelayedRender } from './DelayedRender';
export { default as ThemeSelector } from './ThemeSelector'; export { default as ThemeSelector } from './ThemeSelector';
export { default as SelectDropDown } from './SelectDropDown'; export { default as SelectDropDown } from './SelectDropDown';
export { default as MultiSelectPop } from './MultiSelectPop'; export { default as MultiSelectPop } from './MultiSelectPop';
export { default as InputWithDropdown } from './InputWithDropDown';
export { default as SelectDropDownPop } from './SelectDropDownPop'; export { default as SelectDropDownPop } from './SelectDropDownPop';
export { default as MultiSelectDropDown } from './MultiSelectDropDown'; export { default as MultiSelectDropDown } from './MultiSelectDropDown';
export { default as DropdownPopup } from './DropdownPopup';

View file

@ -143,9 +143,13 @@ export default {
com_ui_manage: 'Manage', com_ui_manage: 'Manage',
com_ui_variables: 'Variables', com_ui_variables: 'Variables',
com_ui_variables_info: com_ui_variables_info:
'Use double braces in your text to create variables, e.g. {{example variable}}, to later fill when using the prompt.', 'Use double braces in your text to create variables, e.g. `{{example variable}}`, to later fill when using the prompt.',
com_ui_special_variables: com_ui_special_variables: 'Special variables:',
'Special variables: Use {{current_date}} for the current date, and {{current_user}} for your given account name.', com_ui_special_variables_info:
'Use `{{current_date}}` for the current date, and `{{current_user}}` for your given account name.',
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_showing: 'Showing', com_ui_showing: 'Showing',
com_ui_of: 'of', com_ui_of: 'of',
com_ui_entries: 'Entries', com_ui_entries: 'Entries',
@ -239,6 +243,7 @@ export default {
com_ui_create_prompt: 'Create Prompt', com_ui_create_prompt: 'Create Prompt',
com_ui_share: 'Share', com_ui_share: 'Share',
com_ui_share_var: 'Share {0}', com_ui_share_var: 'Share {0}',
com_ui_enter_var: 'Enter {0}',
com_ui_copy_link: 'Copy link', com_ui_copy_link: 'Copy link',
com_ui_update_link: 'Update link', com_ui_update_link: 'Update link',
com_ui_create_link: 'Create link', com_ui_create_link: 'Create link',

View file

@ -71,3 +71,14 @@ export const defaultTextPropsLabel =
export function capitalizeFirstLetter(string: string) { export function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1); return string.charAt(0).toUpperCase() + string.slice(1);
} }
export const handleDoubleClick: React.MouseEventHandler<HTMLElement> = (event) => {
const range = document.createRange();
range.selectNodeContents(event.target as Node);
const selection = window.getSelection();
if (!selection) {
return;
}
selection.removeAllRanges();
selection.addRange(range);
};