diff --git a/.gitignore b/.gitignore
index 980be5b8eb..ff2ae59633 100644
--- a/.gitignore
+++ b/.gitignore
@@ -171,5 +171,7 @@ claude-flow.config.json
*.sqlite-journal
*.sqlite-wal
claude-flow
+.playwright-mcp/*
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
+CLAUDE.md
diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js
index ab110443dc..a38188d2a6 100644
--- a/api/server/middleware/limiters/index.js
+++ b/api/server/middleware/limiters/index.js
@@ -8,6 +8,7 @@ const forkLimiters = require('./forkLimiters');
const registerLimiter = require('./registerLimiter');
const toolCallLimiter = require('./toolCallLimiter');
const messageLimiters = require('./messageLimiters');
+const promptUsageLimiter = require('./promptUsageLimiter');
const verifyEmailLimiter = require('./verifyEmailLimiter');
const resetPasswordLimiter = require('./resetPasswordLimiter');
@@ -16,6 +17,7 @@ module.exports = {
...importLimiters,
...messageLimiters,
...forkLimiters,
+ ...promptUsageLimiter,
loginLimiter,
registerLimiter,
toolCallLimiter,
diff --git a/api/server/middleware/limiters/promptUsageLimiter.js b/api/server/middleware/limiters/promptUsageLimiter.js
new file mode 100644
index 0000000000..38bdeed636
--- /dev/null
+++ b/api/server/middleware/limiters/promptUsageLimiter.js
@@ -0,0 +1,17 @@
+const rateLimit = require('express-rate-limit');
+const { limiterCache } = require('@librechat/api');
+
+const PROMPT_USAGE_WINDOW_MS = 60 * 1000; // 1 minute
+const PROMPT_USAGE_MAX = 30; // 30 usage increments per user per minute
+
+const promptUsageLimiter = rateLimit({
+ windowMs: PROMPT_USAGE_WINDOW_MS,
+ max: PROMPT_USAGE_MAX,
+ handler: (_req, res) => {
+ res.status(429).json({ message: 'Too many prompt usage requests. Try again later' });
+ },
+ keyGenerator: (req) => req.user?.id,
+ store: limiterCache('prompt_usage_limiter'),
+});
+
+module.exports = { promptUsageLimiter };
diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js
index 60165d367b..5fcf51ba73 100644
--- a/api/server/routes/prompts.js
+++ b/api/server/routes/prompts.js
@@ -1,5 +1,6 @@
const express = require('express');
-const { logger } = require('@librechat/data-schemas');
+const { ObjectId } = require('mongodb');
+const { logger, isValidObjectIdString } = require('@librechat/data-schemas');
const {
generateCheckAccess,
markPublicPromptGroups,
@@ -20,6 +21,8 @@ const {
const { SystemCapabilities } = require('@librechat/data-schemas');
const {
getListPromptGroupsByAccess,
+ getOwnedPromptGroupIds,
+ incrementPromptGroupUsage,
makePromptProduction,
updatePromptGroup,
deletePromptGroup,
@@ -34,6 +37,7 @@ const {
const {
canAccessPromptGroupResource,
canAccessPromptViaGroup,
+ promptUsageLimiter,
requireJwtAuth,
} = require('~/server/middleware');
const {
@@ -60,6 +64,12 @@ const checkPromptCreate = generateCheckAccess({
router.use(requireJwtAuth);
router.use(checkPromptAccess);
+const checkGlobalPromptShare = generateCheckAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
/**
* Route to get single prompt group by its ID
* GET /groups/:groupId
@@ -94,11 +104,10 @@ router.get(
router.get('/all', async (req, res) => {
try {
const userId = req.user.id;
- const { name, category, ...otherFilters } = req.query;
+ const { name, category } = req.query;
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
name,
category,
- ...otherFilters,
});
let accessibleIds = await findAccessibleResources({
@@ -108,16 +117,20 @@ router.get('/all', async (req, res) => {
requiredPermissions: PermissionBits.VIEW,
});
- const publiclyAccessibleIds = await findPubliclyAccessibleResources({
- resourceType: ResourceType.PROMPTGROUP,
- requiredPermissions: PermissionBits.VIEW,
- });
+ const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
+ findPubliclyAccessibleResources({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ getOwnedPromptGroupIds(userId),
+ ]);
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
accessibleIds,
searchShared,
searchSharedOnly,
publicPromptGroupIds: publiclyAccessibleIds,
+ ownedPromptGroupIds,
});
const result = await getListPromptGroupsByAccess({
@@ -149,12 +162,11 @@ router.get('/all', async (req, res) => {
router.get('/groups', async (req, res) => {
try {
const userId = req.user.id;
- const { pageSize, limit, cursor, name, category, ...otherFilters } = req.query;
+ const { pageSize, limit, cursor, name, category } = req.query;
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
name,
category,
- ...otherFilters,
});
let actualLimit = limit;
@@ -178,16 +190,20 @@ router.get('/groups', async (req, res) => {
requiredPermissions: PermissionBits.VIEW,
});
- const publiclyAccessibleIds = await findPubliclyAccessibleResources({
- resourceType: ResourceType.PROMPTGROUP,
- requiredPermissions: PermissionBits.VIEW,
- });
+ const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([
+ findPubliclyAccessibleResources({
+ resourceType: ResourceType.PROMPTGROUP,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ getOwnedPromptGroupIds(userId),
+ ]);
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
accessibleIds,
searchShared,
searchSharedOnly,
publicPromptGroupIds: publiclyAccessibleIds,
+ ownedPromptGroupIds,
});
// Cursor-based pagination only
@@ -291,6 +307,16 @@ const addPromptToGroup = async (req, res) => {
return res.status(400).send({ error: 'Prompt is required' });
}
+ if (typeof prompt.prompt !== 'string' || !prompt.prompt.trim()) {
+ return res
+ .status(400)
+ .send({ error: 'Prompt text is required and must be a non-empty string' });
+ }
+
+ if (prompt.type !== 'text' && prompt.type !== 'chat') {
+ return res.status(400).send({ error: 'Prompt type must be "text" or "chat"' });
+ }
+
// Ensure the prompt is associated with the correct group
prompt.groupId = groupId;
@@ -321,6 +347,37 @@ router.post(
addPromptToGroup,
);
+/**
+ * Records a prompt group usage (increments numberOfGenerations)
+ * POST /groups/:groupId/use
+ */
+router.post(
+ '/groups/:groupId/use',
+ promptUsageLimiter,
+ canAccessPromptGroupResource({
+ requiredPermission: PermissionBits.VIEW,
+ }),
+ async (req, res) => {
+ try {
+ const { groupId } = req.params;
+ if (!isValidObjectIdString(groupId)) {
+ return res.status(400).send({ error: 'Invalid groupId' });
+ }
+ const result = await incrementPromptGroupUsage(groupId);
+ res.status(200).send(result);
+ } catch (error) {
+ logger.error('[recordPromptUsage]', error);
+ if (error.message === 'Invalid groupId') {
+ return res.status(400).send({ error: 'Invalid groupId' });
+ }
+ if (error.message === 'Prompt group not found') {
+ return res.status(404).send({ error: 'Prompt group not found' });
+ }
+ res.status(500).send({ error: 'Error recording prompt usage' });
+ }
+ },
+);
+
/**
* Updates a prompt group
* @param {object} req
@@ -332,18 +389,8 @@ router.post(
const patchPromptGroup = async (req, res) => {
try {
const { groupId } = req.params;
- const author = req.user.id;
- const filter = { _id: groupId, author };
- let canManagePrompts = false;
- try {
- canManagePrompts = await hasCapability(req.user, SystemCapabilities.MANAGE_PROMPTS);
- } catch (err) {
- logger.warn(`[patchPromptGroup] capability check failed, denying bypass: ${err.message}`);
- }
- if (canManagePrompts) {
- logger.debug(`[patchPromptGroup] MANAGE_PROMPTS bypass for user ${req.user.id}`);
- delete filter.author;
- }
+ // Don't pass author - permissions are now checked by middleware
+ const filter = { _id: groupId };
const validationResult = safeValidatePromptGroupUpdate(req.body);
if (!validationResult.success) {
@@ -363,7 +410,7 @@ const patchPromptGroup = async (req, res) => {
router.patch(
'/groups/:groupId',
- checkPromptCreate,
+ checkGlobalPromptShare,
canAccessPromptGroupResource({
requiredPermission: PermissionBits.EDIT,
}),
@@ -409,6 +456,10 @@ router.get('/', async (req, res) => {
// If requesting prompts for a specific group, check permissions
if (groupId) {
+ if (!isValidObjectIdString(groupId)) {
+ return res.status(400).send({ error: 'Invalid groupId' });
+ }
+
const permissions = await getEffectivePermissions({
userId: req.user.id,
role: req.user.role,
@@ -423,7 +474,7 @@ router.get('/', async (req, res) => {
}
// If user has access, fetch all prompts in the group (not just their own)
- const prompts = await getPrompts({ groupId });
+ const prompts = await getPrompts({ groupId: new ObjectId(groupId) });
return res.status(200).send(prompts);
}
@@ -460,6 +511,9 @@ const deletePromptController = async (req, res) => {
try {
const { promptId } = req.params;
const { groupId } = req.query;
+ if (!groupId || !isValidObjectIdString(groupId)) {
+ return res.status(400).send({ error: 'Invalid or missing groupId' });
+ }
const query = { promptId, groupId };
const result = await deletePrompt(query);
res.status(200).send(result);
diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js
index a3b868f022..c979023ffc 100644
--- a/api/server/routes/prompts.test.js
+++ b/api/server/routes/prompts.test.js
@@ -36,6 +36,7 @@ jest.mock('~/models', () => {
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
+ promptUsageLimiter: (req, res, next) => next(),
canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup,
canAccessPromptGroupResource:
jest.requireActual('~/server/middleware').canAccessPromptGroupResource,
diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx
index 7c9dbe8258..3df373b165 100644
--- a/client/src/Providers/PromptGroupsContext.tsx
+++ b/client/src/Providers/PromptGroupsContext.tsx
@@ -2,9 +2,9 @@ import React, { createContext, useContext, ReactNode, useMemo } from 'react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
-import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
import { useGetAllPromptGroups } from '~/data-provider';
+import { CategoryIcon } from '~/components/Prompts';
import { mapPromptGroups } from '~/utils';
type AllPromptGroupsData =
diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx
index 1740ed43a2..f05a46f6ac 100644
--- a/client/src/components/Chat/Input/PromptsCommand.tsx
+++ b/client/src/components/Chat/Input/PromptsCommand.tsx
@@ -4,8 +4,9 @@ import { Spinner, useCombobox } from '@librechat/client';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
-import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import { removeCharIfLast, detectVariables } from '~/utils';
+import { useRecordPromptUsage } from '~/data-provider';
+import { VariableDialog } from '~/components/Prompts';
import { usePromptGroupsContext } from '~/Providers';
import MentionItem from './MentionItem';
import { useLocalize } from '~/hooks';
@@ -60,6 +61,7 @@ function PromptsCommand({
submitPrompt: (textPrompt: string) => void;
}) {
const localize = useLocalize();
+ const { mutate: recordUsage } = useRecordPromptUsage();
const { allPromptGroups, hasAccess } = usePromptGroupsContext();
const { data, isLoading } = allPromptGroups;
@@ -107,9 +109,20 @@ function PromptsCommand({
return;
} else {
submitPrompt(group.productionPrompt?.prompt ?? '');
+ if (group._id) {
+ recordUsage(group._id);
+ }
}
},
- [setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt],
+ [
+ setSearchValue,
+ setOpen,
+ setShowPromptsPopover,
+ textAreaRef,
+ promptsMap,
+ submitPrompt,
+ recordUsage,
+ ],
);
useEffect(() => {
diff --git a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx
index 5cbbd73619..e68d008f3d 100644
--- a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx
+++ b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx
@@ -7,6 +7,20 @@ import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const toggleSwitchConfigs = [
+ {
+ stateAtom: store.alwaysMakeProd,
+ localizationKey: 'com_nav_always_make_prod' as const,
+ switchId: 'alwaysMakeProd',
+ hoverCardText: undefined,
+ key: 'alwaysMakeProd',
+ },
+ {
+ stateAtom: store.autoSendPrompts,
+ localizationKey: 'com_nav_auto_send_prompts' as const,
+ switchId: 'autoSendPrompts',
+ hoverCardText: 'com_nav_auto_send_prompts_desc' as const,
+ key: 'autoSendPrompts',
+ },
{
stateAtom: store.enterToSend,
localizationKey: 'com_nav_enter_to_send' as const,
diff --git a/client/src/components/Prompts/AdvancedSwitch.tsx b/client/src/components/Prompts/AdvancedSwitch.tsx
deleted file mode 100644
index b050f6b343..0000000000
--- a/client/src/components/Prompts/AdvancedSwitch.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useRecoilState, useSetRecoilState } from 'recoil';
-import { PromptsEditorMode } from '~/common';
-import { useLocalize } from '~/hooks';
-import store from '~/store';
-
-const { promptsEditorMode, alwaysMakeProd } = store;
-
-const AdvancedSwitch = () => {
- const localize = useLocalize();
- const [mode, setMode] = useRecoilState(promptsEditorMode);
- const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
-
- return (
-
-
-
-
- {/* Simple Mode Button */}
-
{
- setAlwaysMakeProd(true);
- setMode(PromptsEditorMode.SIMPLE);
- }}
- aria-pressed={mode === PromptsEditorMode.SIMPLE}
- aria-label={localize('com_ui_simple')}
- className={`relative z-10 flex-1 rounded-xl px-3 py-2 text-sm transition-all duration-300 md:px-6 ${
- mode === PromptsEditorMode.SIMPLE
- ? 'font-bold text-text-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
- {localize('com_ui_simple')}
-
-
- {/* Advanced Mode Button */}
-
setMode(PromptsEditorMode.ADVANCED)}
- aria-pressed={mode === PromptsEditorMode.ADVANCED}
- aria-label={localize('com_ui_advanced')}
- className={`relative z-10 flex-1 rounded-xl px-3 py-2 text-sm transition-all duration-300 md:px-6 ${
- mode === PromptsEditorMode.ADVANCED
- ? 'font-bold text-text-primary'
- : 'text-text-secondary hover:text-text-primary'
- }`}
- >
- {localize('com_ui_advanced')}
-
-
-
- );
-};
-
-export default AdvancedSwitch;
diff --git a/client/src/components/Prompts/BackToChat.tsx b/client/src/components/Prompts/BackToChat.tsx
deleted file mode 100644
index c8e28ba2fe..0000000000
--- a/client/src/components/Prompts/BackToChat.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ArrowLeft } from 'lucide-react';
-import { useNavigate } from 'react-router-dom';
-import { buttonVariants } from '@librechat/client';
-import { useLocalize } from '~/hooks';
-import { cn } from '~/utils';
-
-export default function BackToChat({ className }: { className?: string }) {
- const navigate = useNavigate();
- const localize = useLocalize();
- const clickHandler = (event: React.MouseEvent) => {
- if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
- event.preventDefault();
- navigate('/c/new');
- }
- };
- return (
-
-
- {localize('com_ui_back_to_chat')}
-
- );
-}
diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx
deleted file mode 100644
index 9c4f149e57..0000000000
--- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import { useState, useMemo, memo, useRef } from 'react';
-import { Link } from 'react-router-dom';
-import { PermissionBits, ResourceType } from 'librechat-data-provider';
-import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react';
-import {
- DropdownMenu,
- DropdownMenuItem,
- DropdownMenuGroup,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from '@librechat/client';
-import type { TPromptGroup } from 'librechat-data-provider';
-import { useLocalize, useSubmitMessage, useResourcePermissions } from '~/hooks';
-import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
-import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
-import ListCard from '~/components/Prompts/Groups/ListCard';
-import { detectVariables } from '~/utils';
-
-function ChatGroupItem({ group }: { group: TPromptGroup }) {
- const localize = useLocalize();
- const { submitPrompt } = useSubmitMessage();
- const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
-
- const groupIsGlobal = useMemo(() => group.isPublic === true, [group.isPublic]);
-
- // Check permissions for the promptGroup
- const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
- const canEdit = hasPermission(PermissionBits.EDIT);
-
- const triggerButtonRef = useRef(null);
-
- const onCardClick: React.MouseEventHandler = () => {
- const text = group.productionPrompt?.prompt;
- if (!text?.trim()) {
- return;
- }
-
- if (detectVariables(text)) {
- setVariableDialogOpen(true);
- return;
- }
-
- submitPrompt(text);
- };
-
- return (
- <>
-
-
0
- ? group.oneliner
- : (group.productionPrompt?.prompt ?? '')
- }
- >
- {groupIsGlobal === true && (
-
-
-
- )}
-
-
-
- {
- e.stopPropagation();
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.stopPropagation();
- }
- }}
- className="z-50 mr-2 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:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
- >
-
-
-
-
-
-
-
- {
- requestAnimationFrame(() => {
- triggerButtonRef.current?.focus({ preventScroll: true });
- });
- }}
- />
- setVariableDialogOpen(false)}
- group={group}
- />
- >
- );
-}
-
-export default memo(ChatGroupItem);
diff --git a/client/src/components/Prompts/Groups/DashGroupItem.tsx b/client/src/components/Prompts/Groups/DashGroupItem.tsx
deleted file mode 100644
index ee8c9acf38..0000000000
--- a/client/src/components/Prompts/Groups/DashGroupItem.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'react';
-import { Trans } from 'react-i18next';
-import { EarthIcon, Pen } from 'lucide-react';
-import { useNavigate, useParams } from 'react-router-dom';
-import { PermissionBits, ResourceType, type TPromptGroup } from 'librechat-data-provider';
-import {
- Input,
- Label,
- OGDialog,
- OGDialogTrigger,
- OGDialogTemplate,
- TrashIcon,
-} from '@librechat/client';
-import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
-import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
-import { useLocalize, useResourcePermissions } from '~/hooks';
-import { useLiveAnnouncer } from '~/Providers';
-import { cn } from '~/utils';
-
-interface DashGroupItemProps {
- group: TPromptGroup;
-}
-
-function DashGroupItemComponent({ group }: DashGroupItemProps) {
- const params = useParams();
- const navigate = useNavigate();
- const localize = useLocalize();
- const { announcePolite } = useLiveAnnouncer();
-
- const blurTimeoutRef = useRef(null);
- const [nameInputValue, setNameInputValue] = useState(group.name);
-
- const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
- const canEdit = hasPermission(PermissionBits.EDIT);
- const canDelete = hasPermission(PermissionBits.DELETE);
-
- const isPublicGroup = useMemo(() => group.isPublic === true, [group.isPublic]);
-
- const updateGroup = useUpdatePromptGroup({
- onMutate: () => {
- if (blurTimeoutRef.current) {
- clearTimeout(blurTimeoutRef.current);
- }
- },
- });
-
- const deleteGroup = useDeletePromptGroup({
- onSuccess: (_response, variables) => {
- if (variables.id === group._id) {
- const announcement = localize('com_ui_prompt_deleted', { 0: group.name });
- announcePolite({ message: announcement, isStatus: true });
- navigate('/d/prompts');
- }
- },
- });
-
- const { isLoading } = updateGroup;
-
- const handleSaveRename = useCallback(() => {
- console.log(group._id ?? '', { name: nameInputValue });
- updateGroup.mutate({ id: group._id ?? '', payload: { name: nameInputValue } });
- }, [group._id, nameInputValue, updateGroup]);
-
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- navigate(`/d/prompts/${group._id}`, { replace: true });
- }
- },
- [group._id, navigate],
- );
-
- const triggerDelete = useCallback(() => {
- deleteGroup.mutate({ id: group._id ?? '' });
- }, [group._id, deleteGroup]);
-
- const handleContainerClick = useCallback(() => {
- navigate(`/d/prompts/${group._id}`, { replace: true });
- }, [group._id, navigate]);
-
- return (
-
-
-
-
-
-
- {group.name}
-
-
-
-
- {isPublicGroup && (
-
- )}
-
-
-
-
- }
- selection={{
- selectHandler: handleSaveRename,
- selectClasses:
- 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit',
- selectText: localize('com_ui_save'),
- isLoading,
- }}
- />
-
- )}
-
- {canDelete && (
-
-
- e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.stopPropagation();
- }
- }}
- className="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:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
- aria-label={localize('com_ui_delete_prompt_name', { name: group.name })}
- >
-
-
-
-
-
-
- }}
- />
-
-
-
- }
- selection={{
- selectHandler: triggerDelete,
- selectClasses:
- 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
- selectText: localize('com_ui_delete'),
- }}
- />
-
- )}
-
-
- );
-}
-
-export default memo(DashGroupItemComponent);
diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx
deleted file mode 100644
index ec19a07068..0000000000
--- a/client/src/components/Prompts/Groups/List.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { FileText, Plus } from 'lucide-react';
-import { Link } from 'react-router-dom';
-import { Button, Skeleton } from '@librechat/client';
-import { PermissionTypes, Permissions } from 'librechat-data-provider';
-import type { TPromptGroup } from 'librechat-data-provider';
-import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
-import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
-import { useLocalize, useHasAccess } from '~/hooks';
-import { cn } from '~/utils';
-
-export default function List({
- groups = [],
- isChatRoute,
- isLoading,
-}: {
- groups?: TPromptGroup[];
- isChatRoute: boolean;
- isLoading: boolean;
-}) {
- const localize = useLocalize();
- const hasCreateAccess = useHasAccess({
- permissionType: PermissionTypes.PROMPTS,
- permission: Permissions.CREATE,
- });
-
- return (
-
- {hasCreateAccess && (
-
-
-
-
- {localize('com_ui_create_prompt')}
-
-
-
- )}
-
-
- {isLoading && isChatRoute && (
-
- )}
- {isLoading &&
- !isChatRoute &&
- Array.from({ length: 10 }).map((_, index: number) => (
-
- ))}
- {!isLoading && groups.length === 0 && (
-
-
-
-
-
- {localize('com_ui_no_prompts_title')}
-
-
- {localize('com_ui_add_first_prompt')}
-
-
- )}
- {groups.map((group) => {
- if (isChatRoute) {
- return
;
- }
- return
;
- })}
-
-
-
- );
-}
diff --git a/client/src/components/Prompts/Groups/ListCard.tsx b/client/src/components/Prompts/Groups/ListCard.tsx
deleted file mode 100644
index 0503a69aa6..0000000000
--- a/client/src/components/Prompts/Groups/ListCard.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-import { Label } from '@librechat/client';
-import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
-import { useLocalize } from '~/hooks';
-
-export default function ListCard({
- category,
- name,
- snippet,
- onClick,
- children,
-}: {
- category: string;
- name: string;
- snippet: string;
- onClick?: React.MouseEventHandler;
- children?: React.ReactNode;
-}) {
- const localize = useLocalize();
- const handleKeyDown = (event: React.KeyboardEvent) => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- onClick?.(event as unknown as React.MouseEvent);
- }
- };
-
- return (
-
-
-
-
-
- {name}
-
-
-
{children}
-
-
- {snippet}
-
-
- );
-}
diff --git a/client/src/components/Prompts/Markdown.tsx b/client/src/components/Prompts/Markdown.tsx
deleted file mode 100644
index 23353ed7ea..0000000000
--- a/client/src/components/Prompts/Markdown.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react';
-import { handleDoubleClick } from '~/utils';
-
-export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
- return (
-
- {children}
-
- );
-};
-
-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 ? (
-
- {`{{${part}}}`}
-
- ) : (
- part
- ),
- );
- };
-
- return {React.Children.map(children, (child) => renderContent(child))}
;
-};
diff --git a/client/src/components/Prompts/PromptDetails.tsx b/client/src/components/Prompts/PromptDetails.tsx
deleted file mode 100644
index f759079d18..0000000000
--- a/client/src/components/Prompts/PromptDetails.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-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 { Label } from '@librechat/client';
-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/MarkdownComponents';
-import { useLocalize, useAuthContext } from '~/hooks';
-import CategoryIcon from './Groups/CategoryIcon';
-import PromptVariables from './PromptVariables';
-import { PromptVariableGfm } from './Markdown';
-import Description from './Description';
-import Command from './Command';
-
-const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
- 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) {
- return null;
- }
-
- return (
-
-
-
-
-
- {(group.category?.length ?? 0) > 0 ? (
-
- ) : null}
-
-
{group.name}
-
-
-
-
-
-
-
- {localize('com_ui_prompt_text')}
-
-
-
- {mainText}
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default PromptDetails;
diff --git a/client/src/components/Prompts/PromptEditor.tsx b/client/src/components/Prompts/PromptEditor.tsx
deleted file mode 100644
index ea0d1ef15c..0000000000
--- a/client/src/components/Prompts/PromptEditor.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { useMemo, memo } from 'react';
-import remarkGfm from 'remark-gfm';
-import remarkMath from 'remark-math';
-import rehypeKatex from 'rehype-katex';
-import supersub from 'remark-supersub';
-import { useRecoilValue } from 'recoil';
-import { EditIcon } from 'lucide-react';
-import ReactMarkdown from 'react-markdown';
-import rehypeHighlight from 'rehype-highlight';
-import { SaveIcon, CrossIcon, TextareaAutosize } from '@librechat/client';
-import { Controller, useFormContext, useFormState } from 'react-hook-form';
-import type { PluggableList } from 'unified';
-import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
-import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
-import VariablesDropdown from './VariablesDropdown';
-import { PromptVariableGfm } from './Markdown';
-import { PromptsEditorMode } from '~/common';
-import { cn, langSubset } from '~/utils';
-import { useLocalize } from '~/hooks';
-import store from '~/store';
-
-const { promptsEditorMode } = store;
-
-type Props = {
- name: string;
- isEditing: boolean;
- setIsEditing: React.Dispatch>;
-};
-
-const PromptEditor: React.FC = ({ name, isEditing, setIsEditing }) => {
- const localize = useLocalize();
- const { control } = useFormContext();
- const editorMode = useRecoilValue(promptsEditorMode);
- const { dirtyFields } = useFormState({ control: control });
- const { prompt } = dirtyFields as { prompt?: string };
-
- const EditorIcon = useMemo(() => {
- if (isEditing && prompt?.length == null) {
- return CrossIcon;
- }
- return isEditing ? SaveIcon : EditIcon;
- }, [isEditing, prompt]);
-
- const rehypePlugins: PluggableList = [
- [rehypeKatex],
- [
- rehypeHighlight,
- {
- detect: true,
- ignoreMissing: true,
- subset: langSubset,
- },
- ],
- ];
-
- return (
-
-
{localize('com_ui_control_bar')}
-
-
- {localize('com_ui_prompt_text')}
-
-
- {editorMode === PromptsEditorMode.ADVANCED && (
-
- )}
-
-
setIsEditing((prev) => !prev)}
- aria-label={isEditing ? localize('com_ui_save') : localize('com_ui_edit')}
- className="mr-1 rounded-lg p-1.5 sm:mr-2 sm:p-1"
- >
-
-
-
-
-
!isEditing && setIsEditing(true)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- !isEditing && setIsEditing(true);
- }
- }}
- tabIndex={0}
- >
- {!isEditing && (
-
- )}
-
- isEditing ? (
- setIsEditing(false)}
- onKeyDown={(e) => {
- if (e.key === 'Escape') {
- e.preventDefault();
- setIsEditing(false);
- }
- }}
- aria-label={localize('com_ui_prompt_input')}
- />
- ) : (
-
-
- {field.value}
-
-
- )
- }
- />
-
-
- );
-};
-
-export default memo(PromptEditor);
diff --git a/client/src/components/Prompts/PromptForm.tsx b/client/src/components/Prompts/PromptForm.tsx
deleted file mode 100644
index 6f575f8577..0000000000
--- a/client/src/components/Prompts/PromptForm.tsx
+++ /dev/null
@@ -1,530 +0,0 @@
-import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
-import React from 'react';
-import debounce from 'lodash/debounce';
-import { useRecoilValue } from 'recoil';
-import { Menu, Rocket } from 'lucide-react';
-import { useParams } from 'react-router-dom';
-import { useForm, FormProvider } from 'react-hook-form';
-import { Button, Skeleton, useToastContext } from '@librechat/client';
-import {
- Permissions,
- ResourceType,
- PermissionBits,
- PermissionTypes,
-} from 'librechat-data-provider';
-import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
-import {
- useGetPrompts,
- useGetPromptGroup,
- useAddPromptToGroup,
- useUpdatePromptGroup,
- useMakePromptProduction,
-} from '~/data-provider';
-import { useResourcePermissions, useHasAccess, useLocalize } from '~/hooks';
-import CategorySelector from './Groups/CategorySelector';
-import { usePromptGroupsContext } from '~/Providers';
-import NoPromptGroup from './Groups/NoPromptGroup';
-import PromptVariables from './PromptVariables';
-import { cn, findPromptGroup } from '~/utils';
-import PromptVersions from './PromptVersions';
-import { PromptsEditorMode } from '~/common';
-import DeleteVersion from './DeleteVersion';
-import PromptDetails from './PromptDetails';
-import PromptEditor from './PromptEditor';
-import SkeletonForm from './SkeletonForm';
-import Description from './Description';
-import SharePrompt from './SharePrompt';
-import PromptName from './PromptName';
-import Command from './Command';
-import store from '~/store';
-
-interface RightPanelProps {
- group: TPromptGroup;
- prompts: TPrompt[];
- selectedPrompt: any;
- selectionIndex: number;
- selectedPromptId?: string;
- isLoadingPrompts: boolean;
- canEdit: boolean;
- setSelectionIndex: React.Dispatch>;
-}
-
-const RightPanel = React.memo(
- ({
- group,
- prompts,
- selectedPrompt,
- selectedPromptId,
- isLoadingPrompts,
- canEdit,
- selectionIndex,
- setSelectionIndex,
- }: RightPanelProps) => {
- const localize = useLocalize();
- const { showToast } = useToastContext();
- const editorMode = useRecoilValue(store.promptsEditorMode);
- const hasShareAccess = useHasAccess({
- permissionType: PermissionTypes.PROMPTS,
- permission: Permissions.SHARE,
- });
-
- const updateGroupMutation = useUpdatePromptGroup({
- onError: () => {
- showToast({
- status: 'error',
- message: localize('com_ui_prompt_update_error'),
- });
- },
- });
-
- const makeProductionMutation = useMakePromptProduction();
-
- const groupId = group?._id || '';
- const groupName = group?.name || '';
- const groupCategory = group?.category || '';
- const isLoadingGroup = !group;
-
- return (
-
-
-
- updateGroupMutation.mutate({
- id: groupId,
- payload: { name: groupName, category: value },
- })
- : undefined
- }
- />
-
- {hasShareAccess && }
- {editorMode === PromptsEditorMode.ADVANCED && canEdit && (
- {
- if (!selectedPrompt) {
- console.warn('No prompt is selected');
- return;
- }
- const { _id: promptVersionId = '', prompt } = selectedPrompt;
- makeProductionMutation.mutate({
- id: promptVersionId,
- groupId,
- productionPrompt: { prompt },
- });
- }}
- disabled={
- isLoadingGroup ||
- !selectedPrompt ||
- selectedPrompt._id === group?.productionId ||
- makeProductionMutation.isLoading ||
- !canEdit
- }
- >
-
-
- )}
-
-
-
- {editorMode === PromptsEditorMode.ADVANCED &&
- (isLoadingPrompts
- ? Array.from({ length: 6 }).map((_, index: number) => (
-
-
-
- ))
- : prompts.length > 0 && (
-
- ))}
-
- );
- },
-);
-
-RightPanel.displayName = 'RightPanel';
-
-const PromptForm = () => {
- const params = useParams();
- const localize = useLocalize();
- const { showToast } = useToastContext();
- const { hasAccess } = usePromptGroupsContext();
- const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
- const promptId = params.promptId || '';
-
- const editorMode = useRecoilValue(store.promptsEditorMode);
- const [selectionIndex, setSelectionIndex] = useState(0);
-
- const prevIsEditingRef = useRef(false);
- const [isEditing, setIsEditing] = useState(false);
- const [initialLoad, setInitialLoad] = useState(true);
- const [showSidePanel, setShowSidePanel] = useState(false);
- const sidePanelWidth = '320px';
-
- const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId, {
- enabled: hasAccess && !!promptId,
- });
- const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
- { groupId: promptId },
- { enabled: hasAccess && !!promptId },
- );
-
- const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
- ResourceType.PROMPTGROUP,
- group?._id || '',
- );
-
- const canEdit = hasPermission(PermissionBits.EDIT);
- const canView = hasPermission(PermissionBits.VIEW);
-
- const methods = useForm({
- defaultValues: {
- prompt: '',
- promptName: group ? group.name : '',
- category: group ? group.category : '',
- },
- });
- const { handleSubmit, setValue, reset, watch } = methods;
- const promptText = watch('prompt');
-
- const selectedPrompt = useMemo(
- () => (prompts.length > 0 ? prompts[selectionIndex] : undefined),
- [prompts, selectionIndex],
- );
-
- const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
-
- const { groupsQuery } = usePromptGroupsContext();
-
- const updateGroupMutation = useUpdatePromptGroup({
- onError: () => {
- showToast({
- status: 'error',
- message: localize('com_ui_prompt_update_error'),
- });
- },
- });
-
- const makeProductionMutation = useMakePromptProduction();
- const addPromptToGroupMutation = useAddPromptToGroup({
- onMutate: (variables) => {
- reset(
- {
- prompt: variables.prompt.prompt,
- category: group?.category || '',
- },
- { keepDirtyValues: true },
- );
- },
- onSuccess(data) {
- if (alwaysMakeProd && data.prompt._id != null && data.prompt._id && data.prompt.groupId) {
- makeProductionMutation.mutate({
- id: data.prompt._id,
- groupId: data.prompt.groupId,
- productionPrompt: { prompt: data.prompt.prompt },
- });
- }
-
- reset({
- prompt: data.prompt.prompt,
- promptName: group?.name || '',
- category: group?.category || '',
- });
- },
- });
-
- const onSave = useCallback(
- (value: string) => {
- if (!canEdit) {
- return;
- }
- if (!value) {
- // TODO: show toast, cannot be empty.
- return;
- }
- if (!selectedPrompt) {
- return;
- }
-
- const groupId = selectedPrompt.groupId || group?._id;
- if (!groupId) {
- console.error('No groupId available');
- return;
- }
-
- const tempPrompt: TCreatePrompt = {
- prompt: {
- type: selectedPrompt.type ?? 'text',
- groupId: groupId,
- prompt: value,
- },
- };
-
- if (value === selectedPrompt.prompt) {
- return;
- }
-
- // We're adding to an existing group, so use the addPromptToGroup mutation
- addPromptToGroupMutation.mutate({ ...tempPrompt, groupId });
- },
- [selectedPrompt, group, addPromptToGroupMutation, canEdit],
- );
-
- const handleLoadingComplete = useCallback(() => {
- if (isLoadingGroup || isLoadingPrompts) {
- return;
- }
- setInitialLoad(false);
- }, [isLoadingGroup, isLoadingPrompts]);
-
- useEffect(() => {
- if (prevIsEditingRef.current && !isEditing && canEdit) {
- handleSubmit((data) => onSave(data.prompt))();
- }
- prevIsEditingRef.current = isEditing;
- }, [isEditing, onSave, handleSubmit, canEdit]);
-
- useEffect(() => {
- handleLoadingComplete();
- }, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
-
- useEffect(() => {
- setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false });
- setValue('category', group ? group.category : '', { shouldDirty: false });
- }, [selectedPrompt, group, setValue]);
-
- useEffect(() => {
- const handleResize = () => {
- if (window.matchMedia('(min-width: 1022px)').matches) {
- setShowSidePanel(false);
- }
- };
-
- window.addEventListener('resize', handleResize);
- return () => window.removeEventListener('resize', handleResize);
- }, []);
-
- const debouncedUpdateOneliner = useMemo(
- () =>
- debounce((groupId: string, oneliner: string, mutate: any) => {
- mutate({ id: groupId, payload: { oneliner } });
- }, 950),
- [],
- );
-
- const debouncedUpdateCommand = useMemo(
- () =>
- debounce((groupId: string, command: string, mutate: any) => {
- mutate({ id: groupId, payload: { command } });
- }, 950),
- [],
- );
-
- const handleUpdateOneliner = useCallback(
- (oneliner: string) => {
- if (!group || !group._id) {
- return console.warn('Group not found');
- }
- debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate);
- },
- [group, updateGroupMutation.mutate, debouncedUpdateOneliner],
- );
-
- const handleUpdateCommand = useCallback(
- (command: string) => {
- if (!group || !group._id) {
- return console.warn('Group not found');
- }
- debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate);
- },
- [group, updateGroupMutation.mutate, debouncedUpdateCommand],
- );
-
- if (initialLoad) {
- return ;
- }
-
- // Show read-only view if user doesn't have edit permission
- if (!canEdit && !permissionsLoading && groupsQuery.data) {
- const fetchedPrompt = findPromptGroup(
- groupsQuery.data,
- (group) => group._id === params.promptId,
- );
- if (!fetchedPrompt && !canView) {
- return ;
- }
-
- if (fetchedPrompt || group) {
- return ;
- }
- }
-
- if (!group || group._id == null) {
- return null;
- }
-
- const groupName = group.name;
-
- return (
-
-
-
- );
-};
-
-export default PromptForm;
diff --git a/client/src/components/Prompts/PromptName.tsx b/client/src/components/Prompts/PromptName.tsx
deleted file mode 100644
index cfb18ec973..0000000000
--- a/client/src/components/Prompts/PromptName.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import React, { useEffect, useState, useRef } from 'react';
-import { Button, Label, Input, EditIcon, SaveIcon } from '@librechat/client';
-
-type Props = {
- name?: string;
- onSave: (newName: string) => void;
-};
-
-const PromptName: React.FC = ({ name, onSave }) => {
- const inputRef = useRef(null);
- const blurTimeoutRef = useRef();
- const [isEditing, setIsEditing] = useState(false);
- const [newName, setNewName] = useState(name);
-
- const handleEditClick = () => {
- setIsEditing(true);
- };
-
- const handleInputChange = (e: React.ChangeEvent) => {
- setNewName(e.target.value);
- };
-
- const saveName = () => {
- const savedName = newName?.trim();
- onSave(savedName || '');
- setIsEditing(false);
- };
-
- const handleSaveClick: React.MouseEventHandler = () => {
- saveName();
- clearTimeout(blurTimeoutRef.current);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Escape') {
- setIsEditing(false);
- setNewName(name);
- }
- if (e.key === 'Enter') {
- saveName();
- }
- };
-
- useEffect(() => {
- if (isEditing) {
- inputRef.current?.focus();
- }
- }, [isEditing]);
-
- useEffect(() => {
- setNewName(name);
- }, [name]);
-
- return (
-
-
- {isEditing ? (
- <>
-
-
-
-
-
- >
- ) : (
- <>
-
- {newName}
-
-
-
-
- >
- )}
-
-
- );
-};
-
-export default PromptName;
diff --git a/client/src/components/Prompts/PromptVariables.tsx b/client/src/components/Prompts/PromptVariables.tsx
deleted file mode 100644
index fbb956e844..0000000000
--- a/client/src/components/Prompts/PromptVariables.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import React, { useMemo } from 'react';
-import { Variable } from 'lucide-react';
-import ReactMarkdown from 'react-markdown';
-import { Separator } from '@librechat/client';
-import { specialVariables } from 'librechat-data-provider';
-import { cn, extractUniqueVariables } from '~/utils';
-import { CodeVariableGfm } from './Markdown';
-import { useLocalize } from '~/hooks';
-
-const specialVariableClasses =
- '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,
- showInfo = true,
-}: {
- promptText: string;
- showInfo?: boolean;
-}) => {
- const localize = useLocalize();
-
- const variables = useMemo(() => {
- return extractUniqueVariables(promptText || '');
- }, [promptText]);
-
- return (
-
-
-
- {localize('com_ui_variables')}
-
-
- {variables.length ? (
-
- {variables.map((variable, index) => (
-
- {specialVariables[variable.toLowerCase()] != null
- ? variable.toLowerCase()
- : variable}
-
- ))}
-
- ) : (
-
-
- {localize('com_ui_variables_info')}
-
-
- )}
-
- {showInfo && (
-
-
-
- {localize('com_ui_special_variables')}
-
-
-
- {localize('com_ui_special_variables_more_info')}
-
-
-
-
-
- {localize('com_ui_dropdown_variables')}
-
-
-
- {localize('com_ui_dropdown_variables_info')}
-
-
-
-
- )}
-
-
- );
-};
-
-export default PromptVariables;
diff --git a/client/src/components/Prompts/PromptVersions.tsx b/client/src/components/Prompts/PromptVersions.tsx
deleted file mode 100644
index d21562edcd..0000000000
--- a/client/src/components/Prompts/PromptVersions.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import React from 'react';
-import { format } from 'date-fns';
-import { Layers3, Crown, Zap } from 'lucide-react';
-import { Tag, TooltipAnchor, Label } from '@librechat/client';
-import type { TPrompt, TPromptGroup } from 'librechat-data-provider';
-import { useLocalize } from '~/hooks';
-import { cn } from '~/utils';
-
-const CombinedStatusIcon = ({ description }: { description: string }) => (
-
-
-
- }
- >
-);
-
-const VersionTags = ({ tags }: { tags: string[] }) => {
- const localize = useLocalize();
- const isLatestAndProduction = tags.includes('latest') && tags.includes('production');
-
- if (isLatestAndProduction) {
- return (
-
-
-
- );
- }
-
- return (
-
- {tags.map((tag, i) => (
- {
- if (tag === 'production') {
- return (
-
-
-
- );
- }
- if (tag === 'latest') {
- return (
-
-
-
- );
- }
- return null;
- })()}
- />
- }
- >
- ))}
-
- );
-};
-
-const VersionCard = ({
- prompt,
- index,
- isSelected,
- totalVersions,
- onClick,
- authorName,
- tags,
-}: {
- prompt: TPrompt;
- index: number;
- isSelected: boolean;
- totalVersions: number;
- onClick: () => void;
- authorName?: string;
- tags: string[];
-}) => {
- const localize = useLocalize();
-
- return (
-
-
-
-
- {localize('com_ui_version_var', { 0: `${totalVersions - index}` })}
-
-
- {format(new Date(prompt.createdAt), 'yyyy-MM-dd HH:mm')}
-
-
-
-
- {authorName && (
-
- {localize('com_ui_by_author', { 0: authorName })}
-
- )}
-
- {tags.length > 0 && }
-
-
-
- );
-};
-
-const PromptVersions = ({
- prompts,
- group,
- selectionIndex,
- setSelectionIndex,
-}: {
- prompts: TPrompt[];
- group?: TPromptGroup;
- selectionIndex: number;
- setSelectionIndex: React.Dispatch>;
-}) => {
- const localize = useLocalize();
-
- return (
-
-
-
-
- {localize('com_ui_versions')}
-
-
-
-
- {prompts.map((prompt: TPrompt, index: number) => {
- const tags: string[] = [];
-
- if (index === 0) {
- tags.push('latest');
- }
-
- if (prompt._id === group?.productionId) {
- tags.push('production');
- }
-
- return (
- setSelectionIndex(index)}
- authorName={group?.authorName}
- tags={tags}
- />
- );
- })}
-
-
- );
-};
-
-export default PromptVersions;
diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx
deleted file mode 100644
index 67ece18cc2..0000000000
--- a/client/src/components/Prompts/PromptsAccordion.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
-import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
-import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
-import { usePromptGroupsContext } from '~/Providers';
-
-export default function PromptsAccordion() {
- const groupsNav = usePromptGroupsContext();
- return (
-
- );
-}
diff --git a/client/src/components/Prompts/VariablesDropdown.tsx b/client/src/components/Prompts/VariablesDropdown.tsx
deleted file mode 100644
index bfde6525c9..0000000000
--- a/client/src/components/Prompts/VariablesDropdown.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { useState, useId } from 'react';
-import { PlusCircle } from 'lucide-react';
-import * as Menu from '@ariakit/react/menu';
-import { useFormContext } from 'react-hook-form';
-import { DropdownPopup } from '@librechat/client';
-import { specialVariables } from 'librechat-data-provider';
-import type { TSpecialVarLabel } from 'librechat-data-provider';
-import { useLiveAnnouncer } from '~/Providers';
-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 { announcePolite } = useLiveAnnouncer();
-
- 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);
- const announcement = localize('com_ui_special_variable_added', { 0: prefix });
- announcePolite({ message: announcement, isStatus: true });
- };
-
- return (
-
-
- );
-}
diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/buttons/AdminSettings.tsx
similarity index 93%
rename from client/src/components/Prompts/AdminSettings.tsx
rename to client/src/components/Prompts/buttons/AdminSettings.tsx
index 6d382fbb91..25e6df05a8 100644
--- a/client/src/components/Prompts/AdminSettings.tsx
+++ b/client/src/components/Prompts/buttons/AdminSettings.tsx
@@ -2,10 +2,10 @@ import { useState } from 'react';
import { ShieldEllipsis } from 'lucide-react';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { OGDialog, OGDialogTemplate, Button, useToastContext } from '@librechat/client';
-import { AdminSettingsDialog } from '~/components/ui';
-import { useUpdatePromptPermissionsMutation } from '~/data-provider';
-import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
+import { useUpdatePromptPermissionsMutation } from '~/data-provider';
+import { AdminSettingsDialog } from '~/components/ui';
+import { useLocalize } from '~/hooks';
const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_prompts_allow_use' },
@@ -43,7 +43,8 @@ const AdminSettings = () => {
{localize('com_ui_admin')}
@@ -88,7 +89,6 @@ const AdminSettings = () => {
menuId="prompt-role-dropdown"
mutation={mutation}
trigger={trigger}
- dialogContentClassName="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4"
onPermissionConfirm={handlePermissionConfirm}
confirmPermissions={[Permissions.USE]}
extraContent={confirmDialog}
diff --git a/client/src/components/Prompts/buttons/AdvancedSwitch.tsx b/client/src/components/Prompts/buttons/AdvancedSwitch.tsx
new file mode 100644
index 0000000000..9941c78e66
--- /dev/null
+++ b/client/src/components/Prompts/buttons/AdvancedSwitch.tsx
@@ -0,0 +1,52 @@
+import { useCallback, useMemo } from 'react';
+import { Sparkles, Layers } from 'lucide-react';
+import { useRecoilState, useSetRecoilState } from 'recoil';
+import { PromptsEditorMode } from '~/common';
+import { Radio } from '@librechat/client';
+import { useLocalize } from '~/hooks';
+import store from '~/store';
+
+const { promptsEditorMode, alwaysMakeProd } = store;
+
+const AdvancedSwitch = () => {
+ const localize = useLocalize();
+ const [mode, setMode] = useRecoilState(promptsEditorMode);
+ const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
+
+ const options = useMemo(
+ () => [
+ {
+ value: PromptsEditorMode.SIMPLE,
+ label: localize('com_ui_simple'),
+ icon: ,
+ },
+ {
+ value: PromptsEditorMode.ADVANCED,
+ label: localize('com_ui_advanced'),
+ icon: ,
+ },
+ ],
+ [localize],
+ );
+
+ const handleChange = useCallback(
+ (value: string) => {
+ if (value === PromptsEditorMode.SIMPLE) {
+ setAlwaysMakeProd(true);
+ }
+ setMode(value as PromptsEditorMode);
+ },
+ [setMode, setAlwaysMakeProd],
+ );
+
+ return (
+
+ );
+};
+
+export default AdvancedSwitch;
diff --git a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx b/client/src/components/Prompts/buttons/AlwaysMakeProd.tsx
similarity index 100%
rename from client/src/components/Prompts/Groups/AlwaysMakeProd.tsx
rename to client/src/components/Prompts/buttons/AlwaysMakeProd.tsx
diff --git a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx b/client/src/components/Prompts/buttons/AutoSendPrompt.tsx
similarity index 52%
rename from client/src/components/Prompts/Groups/AutoSendPrompt.tsx
rename to client/src/components/Prompts/buttons/AutoSendPrompt.tsx
index 182580a49c..c2ebee70c5 100644
--- a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx
+++ b/client/src/components/Prompts/buttons/AutoSendPrompt.tsx
@@ -1,15 +1,12 @@
import { useRecoilState } from 'recoil';
-import { Switch } from '@librechat/client';
+import { Button, Checkbox } from '@librechat/client';
import { useLocalize } from '~/hooks';
-import { cn } from '~/utils';
import store from '~/store';
export default function AutoSendPrompt({
onCheckedChange,
- className = '',
}: {
onCheckedChange?: (value: boolean) => void;
- className?: string;
}) {
const [autoSendPrompts, setAutoSendPrompts] = useRecoilState(store.autoSendPrompts);
const localize = useLocalize();
@@ -22,20 +19,21 @@ export default function AutoSendPrompt({
};
return (
- handleCheckedChange(!autoSendPrompts)}
+ aria-label={localize('com_nav_auto_send_prompts')}
+ aria-pressed={autoSendPrompts}
+ className={autoSendPrompts ? 'bg-surface-hover hover:bg-surface-hover' : ''}
>
-
{localize('com_nav_auto_send_prompts')}
-
-
+ {localize('com_nav_auto_send_prompts')}
+
);
}
diff --git a/client/src/components/Prompts/buttons/BackToChat.tsx b/client/src/components/Prompts/buttons/BackToChat.tsx
new file mode 100644
index 0000000000..0840e87f46
--- /dev/null
+++ b/client/src/components/Prompts/buttons/BackToChat.tsx
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+import { ArrowLeft } from 'lucide-react';
+import { buttonVariants } from '@librechat/client';
+import { useDashboardContext } from '~/Providers';
+import { useLocalize, useCustomLink } from '~/hooks';
+import { cn } from '~/utils';
+
+export default function BackToChat({ className }: { className?: string }) {
+ const localize = useLocalize();
+ const { prevLocationPath } = useDashboardContext();
+
+ const conversationId = useMemo(() => {
+ if (!prevLocationPath || prevLocationPath.includes('/d/')) {
+ return 'new';
+ }
+ const parts = prevLocationPath.split('/');
+ return parts[parts.length - 1];
+ }, [prevLocationPath]);
+
+ const href = `/c/${conversationId}`;
+ const clickHandler = useCustomLink(href);
+
+ return (
+
+
+ {localize('com_ui_back_to_chat')}
+
+ );
+}
diff --git a/client/src/components/Prompts/buttons/CreatePromptButton.tsx b/client/src/components/Prompts/buttons/CreatePromptButton.tsx
new file mode 100644
index 0000000000..016721d509
--- /dev/null
+++ b/client/src/components/Prompts/buttons/CreatePromptButton.tsx
@@ -0,0 +1,37 @@
+import { Plus } from 'lucide-react';
+import { Link } from 'react-router-dom';
+import { Button, TooltipAnchor } from '@librechat/client';
+import { PermissionTypes, Permissions } from 'librechat-data-provider';
+import { useHasAccess, useLocalize } from '~/hooks';
+
+export default function CreatePromptButton() {
+ const localize = useLocalize();
+ const hasCreateAccess = useHasAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permission: Permissions.CREATE,
+ });
+
+ if (!hasCreateAccess) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ }
+ />
+ );
+}
diff --git a/client/src/components/Prompts/ManagePrompts.tsx b/client/src/components/Prompts/buttons/ManagePrompts.tsx
similarity index 94%
rename from client/src/components/Prompts/ManagePrompts.tsx
rename to client/src/components/Prompts/buttons/ManagePrompts.tsx
index 5dce645bb9..3a98139899 100644
--- a/client/src/components/Prompts/ManagePrompts.tsx
+++ b/client/src/components/Prompts/buttons/ManagePrompts.tsx
@@ -24,8 +24,7 @@ export default function ManagePrompts({ className }: { className?: string }) {
variant="outline"
className={cn(className, 'bg-transparent')}
onClick={clickHandler}
- aria-label="Manage Prompts"
- role="button"
+ aria-label={localize('com_ui_manage')}
>
{localize('com_ui_manage')}
diff --git a/client/src/components/Prompts/buttons/index.ts b/client/src/components/Prompts/buttons/index.ts
new file mode 100644
index 0000000000..c4a1c1ba55
--- /dev/null
+++ b/client/src/components/Prompts/buttons/index.ts
@@ -0,0 +1,7 @@
+export { default as BackToChat } from './BackToChat';
+export { default as ManagePrompts } from './ManagePrompts';
+export { default as AdminSettings } from './AdminSettings';
+export { default as AdvancedSwitch } from './AdvancedSwitch';
+export { default as AlwaysMakeProd } from './AlwaysMakeProd';
+export { default as AutoSendPrompt } from './AutoSendPrompt';
+export { default as CreatePromptButton } from './CreatePromptButton';
diff --git a/client/src/components/Prompts/DeleteVersion.tsx b/client/src/components/Prompts/dialogs/DeletePrompt.tsx
similarity index 63%
rename from client/src/components/Prompts/DeleteVersion.tsx
rename to client/src/components/Prompts/dialogs/DeletePrompt.tsx
index 428be89c91..c58ab2aba7 100644
--- a/client/src/components/Prompts/DeleteVersion.tsx
+++ b/client/src/components/Prompts/dialogs/DeletePrompt.tsx
@@ -1,7 +1,13 @@
import React, { useCallback } from 'react';
import { Trash2 } from 'lucide-react';
+import {
+ Button,
+ OGDialog,
+ OGDialogTrigger,
+ TooltipAnchor,
+ OGDialogTemplate,
+} from '@librechat/client';
import { useDeletePrompt } from '~/data-provider';
-import { Button, OGDialog, OGDialogTrigger, Label, OGDialogTemplate } from '@librechat/client';
import { useLocalize } from '~/hooks';
const DeleteConfirmDialog = ({
@@ -18,36 +24,36 @@ const DeleteConfirmDialog = ({
return (
- {
- e.stopPropagation();
- }}
- >
-
-
+ {
+ e.stopPropagation();
+ }}
+ >
+
+
+ }
+ />
-
-
-
- {localize('com_ui_delete_confirm_prompt_version_var', { 0: name })}
-
-
+
+
+
+ {localize('com_ui_delete_confirm_prompt_version_var', { 0: name })}
+
- >
+
}
selection={{
selectHandler,
@@ -73,7 +79,6 @@ const DeletePrompt = React.memo(
const handleDelete = useCallback(() => {
if (!promptId) {
- console.warn('No prompt ID provided for deletion');
return;
}
deletePromptMutation.mutate({
diff --git a/client/src/components/Prompts/PreviewPrompt.tsx b/client/src/components/Prompts/dialogs/PreviewPrompt.tsx
similarity index 53%
rename from client/src/components/Prompts/PreviewPrompt.tsx
rename to client/src/components/Prompts/dialogs/PreviewPrompt.tsx
index bee4eb09f6..19cc9e6adb 100644
--- a/client/src/components/Prompts/PreviewPrompt.tsx
+++ b/client/src/components/Prompts/dialogs/PreviewPrompt.tsx
@@ -1,6 +1,7 @@
-import { OGDialogContent, OGDialog } from '@librechat/client';
+import { OGDialogContent, OGDialog, OGDialogTitle } from '@librechat/client';
import type { TPromptGroup } from 'librechat-data-provider';
-import PromptDetails from './PromptDetails';
+import PromptDetails from '../display/PromptDetails';
+import { useLocalize } from '~/hooks';
const PreviewPrompt = ({
group,
@@ -13,15 +14,15 @@ const PreviewPrompt = ({
onOpenChange: (open: boolean) => void;
onCloseAutoFocus?: () => void;
}) => {
+ const localize = useLocalize();
return (
-
+ {localize('com_ui_preview')}
+ onOpenChange(false)} />
);
diff --git a/client/src/components/Prompts/SharePrompt.tsx b/client/src/components/Prompts/dialogs/SharePrompt.tsx
similarity index 73%
rename from client/src/components/Prompts/SharePrompt.tsx
rename to client/src/components/Prompts/dialogs/SharePrompt.tsx
index 65e8f20f24..0f0c4337ed 100644
--- a/client/src/components/Prompts/SharePrompt.tsx
+++ b/client/src/components/Prompts/dialogs/SharePrompt.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Share2Icon } from 'lucide-react';
+import { Button, TooltipAnchor } from '@librechat/client';
import {
SystemRoles,
Permissions,
@@ -7,14 +8,14 @@ import {
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
-import { Button } from '@librechat/client';
import type { TPromptGroup } from 'librechat-data-provider';
-import { useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
+import { useAuthContext, useHasAccess, useLocalize, useResourcePermissions } from '~/hooks';
import { GenericGrantAccessDialog } from '~/components/Sharing';
const SharePrompt = React.memo(
({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => {
const { user } = useAuthContext();
+ const localize = useLocalize();
// Check if user has permission to share prompts
const hasAccessToSharePrompts = useHasAccess({
@@ -53,15 +54,20 @@ const SharePrompt = React.memo(
resourceType={ResourceType.PROMPTGROUP}
disabled={disabled}
>
-
-
-
+
+
+
+ }
+ />
);
},
diff --git a/client/src/components/Prompts/Groups/VariableDialog.tsx b/client/src/components/Prompts/dialogs/VariableDialog.tsx
similarity index 89%
rename from client/src/components/Prompts/Groups/VariableDialog.tsx
rename to client/src/components/Prompts/dialogs/VariableDialog.tsx
index 7286b00b63..8f59c3ee66 100644
--- a/client/src/components/Prompts/Groups/VariableDialog.tsx
+++ b/client/src/components/Prompts/dialogs/VariableDialog.tsx
@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
-import type { TPromptGroup } from 'librechat-data-provider';
import { OGDialog, OGDialogTitle, OGDialogContent } from '@librechat/client';
+import type { TPromptGroup } from 'librechat-data-provider';
+import VariableForm from '../forms/VariableForm';
import { detectVariables } from '~/utils';
-import VariableForm from './VariableForm';
interface VariableDialogProps extends Omit {
onClose: () => void;
@@ -31,7 +31,7 @@ const VariableDialog: React.FC = ({ open, onClose, group })
return (
-
+
{group.name}
diff --git a/client/src/components/Prompts/dialogs/index.ts b/client/src/components/Prompts/dialogs/index.ts
new file mode 100644
index 0000000000..e734c55027
--- /dev/null
+++ b/client/src/components/Prompts/dialogs/index.ts
@@ -0,0 +1,4 @@
+export { default as SharePrompt } from './SharePrompt';
+export { default as PreviewPrompt } from './PreviewPrompt';
+export { default as DeleteVersion } from './DeletePrompt';
+export { default as VariableDialog } from './VariableDialog';
diff --git a/client/src/components/Prompts/EmptyPromptPreview.tsx b/client/src/components/Prompts/display/EmptyPromptPreview.tsx
similarity index 100%
rename from client/src/components/Prompts/EmptyPromptPreview.tsx
rename to client/src/components/Prompts/display/EmptyPromptPreview.tsx
diff --git a/client/src/components/Prompts/display/PromptActions.tsx b/client/src/components/Prompts/display/PromptActions.tsx
new file mode 100644
index 0000000000..7c04b54999
--- /dev/null
+++ b/client/src/components/Prompts/display/PromptActions.tsx
@@ -0,0 +1,66 @@
+import { useState, useMemo, useCallback } from 'react';
+import { Send } from 'lucide-react';
+import { Button } from '@librechat/client';
+import type { TPromptGroup } from 'librechat-data-provider';
+import { useLocalize, useSubmitMessage } from '~/hooks';
+import { useRecordPromptUsage } from '~/data-provider';
+import VariableDialog from '../dialogs/VariableDialog';
+import SharePrompt from '../dialogs/SharePrompt';
+import { detectVariables } from '~/utils';
+
+interface PromptActionsProps {
+ group: TPromptGroup;
+ mainText: string;
+ onUsePrompt?: () => void;
+}
+
+const PromptActions = ({ group, mainText, onUsePrompt }: PromptActionsProps) => {
+ const localize = useLocalize();
+ const { submitPrompt } = useSubmitMessage();
+ const { mutate: recordUsage } = useRecordPromptUsage();
+ const [showVariableDialog, setShowVariableDialog] = useState(false);
+
+ const hasVariables = useMemo(
+ () => detectVariables(group.productionPrompt?.prompt ?? ''),
+ [group.productionPrompt?.prompt],
+ );
+
+ const handleUsePrompt = useCallback(() => {
+ if (hasVariables) {
+ setShowVariableDialog(true);
+ } else {
+ submitPrompt(mainText);
+ if (group._id) {
+ recordUsage(group._id);
+ }
+ onUsePrompt?.();
+ }
+ }, [hasVariables, submitPrompt, mainText, onUsePrompt, group._id, recordUsage]);
+
+ const handleVariableDialogClose = useCallback(() => {
+ setShowVariableDialog(false);
+ onUsePrompt?.();
+ }, [onUsePrompt]);
+
+ return (
+ <>
+
+
+
+
+
+ {localize('com_ui_use_prompt')}
+
+
+
+
+ >
+ );
+};
+
+export default PromptActions;
diff --git a/client/src/components/Prompts/display/PromptDetailHeader.tsx b/client/src/components/Prompts/display/PromptDetailHeader.tsx
new file mode 100644
index 0000000000..d63e8536ca
--- /dev/null
+++ b/client/src/components/Prompts/display/PromptDetailHeader.tsx
@@ -0,0 +1,74 @@
+import { format } from 'date-fns';
+import { TooltipAnchor } from '@librechat/client';
+import { User, Calendar, EarthIcon, BarChart3 } from 'lucide-react';
+import type { TPromptGroup } from 'librechat-data-provider';
+import { useLocalize, useAuthContext } from '~/hooks';
+import CategoryIcon from '../utils/CategoryIcon';
+
+interface PromptDetailHeaderProps {
+ group: TPromptGroup;
+}
+
+const PromptDetailHeader = ({ group }: PromptDetailHeaderProps) => {
+ const localize = useLocalize();
+ const { user } = useAuthContext();
+ const formattedDate = group.createdAt ? format(new Date(group.createdAt), 'MMM d, yyyy') : null;
+ const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
+
+ const isGlobalGroup = group.isPublic === true;
+
+ return (
+
+ {group.category && (
+
+
+
+ )}
+
+
+
+
+ {group.name}
+
+ {isGlobalGroup && (
+
+ }
+ />
+ )}
+
+ {group.oneliner && (
+
{group.oneliner}
+ )}
+
+ {isSharedPrompt && (
+
+
+ {localize('com_ui_by_author', { 0: group.authorName })}
+
+ )}
+ {formattedDate && (
+
+
+ {formattedDate}
+
+ )}
+ {group.numberOfGenerations != null && group.numberOfGenerations > 0 && (
+
+
+ {localize('com_ui_usage')}: {group.numberOfGenerations}
+
+ )}
+
+
+
+ );
+};
+
+export default PromptDetailHeader;
diff --git a/client/src/components/Prompts/display/PromptDetails.tsx b/client/src/components/Prompts/display/PromptDetails.tsx
new file mode 100644
index 0000000000..5d4cd8d41b
--- /dev/null
+++ b/client/src/components/Prompts/display/PromptDetails.tsx
@@ -0,0 +1,56 @@
+import { useMemo } from 'react';
+import { SquareSlash } from 'lucide-react';
+import { replaceSpecialVars } from 'librechat-data-provider';
+import type { TPromptGroup } from 'librechat-data-provider';
+import { useLocalize, useAuthContext } from '~/hooks';
+import PromptDetailHeader from './PromptDetailHeader';
+import PromptVariables from './PromptVariables';
+import PromptTextCard from './PromptTextCard';
+import PromptActions from './PromptActions';
+
+interface PromptDetailsProps {
+ group?: TPromptGroup;
+ showActions?: boolean;
+ onUsePrompt?: () => void;
+}
+
+const PromptDetails = ({ group, showActions = true, onUsePrompt }: PromptDetailsProps) => {
+ 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) {
+ return null;
+ }
+
+ return (
+
+ {group.name}
+
+
+
+
+
+
+ {group.command && (
+
+
+ /{group.command}
+
+ )}
+
+ {showActions && }
+
+ );
+};
+
+export default PromptDetails;
diff --git a/client/src/components/Prompts/display/PromptTextCard.tsx b/client/src/components/Prompts/display/PromptTextCard.tsx
new file mode 100644
index 0000000000..c9d5dac4dd
--- /dev/null
+++ b/client/src/components/Prompts/display/PromptTextCard.tsx
@@ -0,0 +1,110 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+import remarkGfm from 'remark-gfm';
+import remarkMath from 'remark-math';
+import rehypeKatex from 'rehype-katex';
+import supersub from 'remark-supersub';
+import ReactMarkdown from 'react-markdown';
+import rehypeHighlight from 'rehype-highlight';
+import { Copy, Check, FileText } from 'lucide-react';
+import { Button, TooltipAnchor, useToastContext } from '@librechat/client';
+import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
+import { PromptVariableGfm } from '../editor/Markdown';
+import { useLocalize } from '~/hooks';
+
+interface PromptTextCardProps {
+ mainText: string;
+}
+
+const PromptTextCard = ({ mainText }: PromptTextCardProps) => {
+ const localize = useLocalize();
+ const { showToast } = useToastContext();
+ const [isCopied, setIsCopied] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
+
+ const handleCopy = useCallback(async () => {
+ if (isCopied) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(mainText);
+ setIsCopied(true);
+ showToast({
+ message: localize('com_ui_copied_to_clipboard'),
+ status: 'success',
+ });
+ timeoutRef.current = setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ } catch {
+ showToast({
+ message: localize('com_ui_copy_failed'),
+ status: 'error',
+ });
+ }
+ }, [mainText, showToast, localize, isCopied]);
+
+ return (
+
+ );
+};
+
+export default PromptTextCard;
diff --git a/client/src/components/Prompts/display/PromptVariables.tsx b/client/src/components/Prompts/display/PromptVariables.tsx
new file mode 100644
index 0000000000..ab6e18cfb7
--- /dev/null
+++ b/client/src/components/Prompts/display/PromptVariables.tsx
@@ -0,0 +1,202 @@
+import { useMemo } from 'react';
+import { Variable, ChevronRight } from 'lucide-react';
+import { specialVariables } from 'librechat-data-provider';
+import type { TSpecialVarLabel } from 'librechat-data-provider';
+import { getSpecialVariableIcon } from '~/components/Prompts/utils';
+import { extractUniqueVariables } from '~/utils';
+import { useLocalize } from '~/hooks';
+
+interface ParsedVariable {
+ name: string;
+ options: string[];
+ isDropdown: boolean;
+ isSpecial: boolean;
+}
+
+const parseVariable = (variable: string): ParsedVariable => {
+ const isSpecial = specialVariables[variable.toLowerCase()] != null;
+ if (isSpecial) {
+ return { name: variable.toLowerCase(), options: [], isDropdown: false, isSpecial: true };
+ }
+
+ const colonIndex = variable.indexOf(':');
+ if (colonIndex > 0) {
+ const name = variable.substring(0, colonIndex);
+ const optionsPart = variable.substring(colonIndex + 1);
+ const options = optionsPart.split('|').filter(Boolean);
+ if (options.length > 1) {
+ return { name, options, isDropdown: true, isSpecial: false };
+ }
+ }
+
+ return { name: variable, options: [], isDropdown: false, isSpecial: false };
+};
+
+const DropdownVariableCard = ({ parsed }: { parsed: ParsedVariable }) => {
+ const localize = useLocalize();
+
+ return (
+
+
+
+
+
+
{parsed.name}
+
+ {parsed.options.length} {localize('com_ui_options')}
+
+
+
+ {parsed.options.map((option, index) => (
+
+ {option}
+
+ ))}
+
+
+ );
+};
+
+const SpecialVariableChip = ({ parsed }: { parsed: ParsedVariable }) => {
+ const localize = useLocalize();
+ const Icon = getSpecialVariableIcon(parsed.name);
+ const labelKey = `com_ui_special_var_${parsed.name}` as TSpecialVarLabel;
+ const descKey = `com_ui_special_var_desc_${parsed.name}` as TSpecialVarLabel;
+ const displayLabel = localize(labelKey);
+ const description = localize(descKey);
+
+ return (
+
+
+
+
+
+
{displayLabel}
+ {description &&
{description}
}
+
+
+ );
+};
+
+const SimpleVariableChip = ({ parsed }: { parsed: ParsedVariable }) => (
+
+
+ {parsed.name}
+
+);
+
+const PromptVariables = ({ promptText }: { promptText: string }) => {
+ const localize = useLocalize();
+
+ const variables = useMemo(() => {
+ return extractUniqueVariables(promptText || '');
+ }, [promptText]);
+
+ const { dropdownVariables, specialVars, simpleVariables } = useMemo(() => {
+ const result = {
+ dropdownVariables: [] as ParsedVariable[],
+ specialVars: [] as ParsedVariable[],
+ simpleVariables: [] as ParsedVariable[],
+ };
+ for (const v of variables) {
+ const parsed = parseVariable(v);
+ if (parsed.isDropdown) {
+ result.dropdownVariables.push(parsed);
+ } else if (parsed.isSpecial) {
+ result.specialVars.push(parsed);
+ } else {
+ result.simpleVariables.push(parsed);
+ }
+ }
+ return result;
+ }, [variables]);
+
+ if (variables.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {specialVars.length > 0 && (
+
+
+ {localize('com_ui_special_variables')}
+
+
+ {specialVars.map((parsed, index) => (
+
+ ))}
+
+
+ )}
+
+ {dropdownVariables.length > 0 && (
+
+
+ {localize('com_ui_dropdown_variables')}
+
+
+ {dropdownVariables.map((parsed, index) => (
+
+ ))}
+
+
+ )}
+
+ {simpleVariables.length > 0 && (
+
+
+ {localize('com_ui_text_variables')}
+
+
+ {simpleVariables.map((parsed, index) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+export default PromptVariables;
diff --git a/client/src/components/Prompts/display/PromptVersions.tsx b/client/src/components/Prompts/display/PromptVersions.tsx
new file mode 100644
index 0000000000..958c344f4a
--- /dev/null
+++ b/client/src/components/Prompts/display/PromptVersions.tsx
@@ -0,0 +1,189 @@
+import React from 'react';
+import { formatDistanceToNow } from 'date-fns';
+import { TooltipAnchor } from '@librechat/client';
+import { Zap, Circle, CheckCircle2 } from 'lucide-react';
+import type { TPrompt, TPromptGroup } from 'librechat-data-provider';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+const VersionBadge = ({
+ type,
+ tooltip,
+ label,
+}: {
+ type: 'latest' | 'production';
+ tooltip: string;
+ label: string;
+}) => {
+ const isProduction = type === 'production';
+
+ return (
+
+ {isProduction ? (
+ <>
+
+ {label}
+ >
+ ) : (
+ <>
+
+ {label}
+ >
+ )}
+
+ }
+ />
+ );
+};
+
+const getTimelineConnectorClasses = (isSelected: boolean, isProduction: boolean) => {
+ if (isSelected) {
+ return 'border-green-500 bg-green-500 text-white';
+ }
+ if (isProduction) {
+ return 'border-green-400 bg-surface-primary text-green-500';
+ }
+ return 'border-border-medium bg-surface-primary text-text-secondary';
+};
+
+const VersionCard = ({
+ prompt,
+ index,
+ isSelected,
+ totalVersions,
+ onClick,
+ isLatest,
+ isProduction,
+}: {
+ prompt: TPrompt;
+ index: number;
+ isSelected: boolean;
+ totalVersions: number;
+ onClick: () => void;
+ isLatest: boolean;
+ isProduction: boolean;
+}) => {
+ const localize = useLocalize();
+ const versionNumber = totalVersions - index;
+
+ return (
+
+ {/* Timeline connector */}
+
+ {/* Vertical line - extends through mb-2 gap + mt-3 to reach next circle */}
+ {index < totalVersions - 1 && (
+
+ )}
+ {/* Circle marker - mt-3 aligns with card title (card has p-3) */}
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Card content */}
+
+
+
+ {localize('com_ui_version_var', { 0: versionNumber })}
+
+
+ {isProduction && (
+
+ )}
+ {isLatest && !isProduction && (
+
+ )}
+
+
+
+
+ {formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })}
+
+
+
+ );
+};
+
+const PromptVersions = ({
+ prompts,
+ group,
+ selectionIndex,
+ setSelectionIndex,
+}: {
+ prompts: TPrompt[];
+ group?: TPromptGroup;
+ selectionIndex: number;
+ setSelectionIndex: React.Dispatch>;
+}) => {
+ const localize = useLocalize();
+ return (
+
+ {prompts.map((prompt: TPrompt, index: number) => {
+ const isLatest = index === 0;
+ const isProduction = prompt._id === group?.productionId;
+
+ return (
+ setSelectionIndex(index)}
+ isLatest={isLatest}
+ isProduction={isProduction}
+ />
+ );
+ })}
+
+ );
+};
+
+export default PromptVersions;
diff --git a/client/src/components/Prompts/display/index.ts b/client/src/components/Prompts/display/index.ts
new file mode 100644
index 0000000000..0ed05b725a
--- /dev/null
+++ b/client/src/components/Prompts/display/index.ts
@@ -0,0 +1,7 @@
+export { default as PromptDetails } from './PromptDetails';
+export { default as PromptActions } from './PromptActions';
+export { default as PromptTextCard } from './PromptTextCard';
+export { default as PromptVersions } from './PromptVersions';
+export { default as PromptVariables } from './PromptVariables';
+export { default as EmptyPromptPreview } from './EmptyPromptPreview';
+export { default as PromptDetailHeader } from './PromptDetailHeader';
diff --git a/client/src/components/Prompts/editor/Markdown.tsx b/client/src/components/Prompts/editor/Markdown.tsx
new file mode 100644
index 0000000000..3be7d3a702
--- /dev/null
+++ b/client/src/components/Prompts/editor/Markdown.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { handleDoubleClick } from '~/utils';
+
+export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const variableRegex = /{{(.*?)}}/g;
+
+const highlightVariables = (text: string): React.ReactNode[] => {
+ const parts = text.split(variableRegex);
+ return parts.map((part, index) => {
+ if (index % 2 === 1) {
+ return (
+
+ {`{{${part}}}`}
+
+ );
+ }
+ return part;
+ });
+};
+
+const processChildren = (children: React.ReactNode): React.ReactNode => {
+ if (typeof children === 'string') {
+ return highlightVariables(children);
+ }
+
+ if (Array.isArray(children)) {
+ return children.map((child, index) => (
+ {processChildren(child)}
+ ));
+ }
+
+ if (React.isValidElement(children)) {
+ const element = children as React.ReactElement<{ children?: React.ReactNode }>;
+ if (element.props.children) {
+ return React.cloneElement(element, {
+ ...element.props,
+ children: processChildren(element.props.children),
+ });
+ }
+ return children;
+ }
+
+ return children;
+};
+
+export const PromptVariableGfm = ({
+ children,
+}: {
+ children: React.ReactNode & React.ReactNode[];
+}) => {
+ return {processChildren(children)}
;
+};
diff --git a/client/src/components/Prompts/editor/PromptEditor.tsx b/client/src/components/Prompts/editor/PromptEditor.tsx
new file mode 100644
index 0000000000..858edbd247
--- /dev/null
+++ b/client/src/components/Prompts/editor/PromptEditor.tsx
@@ -0,0 +1,154 @@
+import { useMemo, memo } from 'react';
+import remarkGfm from 'remark-gfm';
+import remarkMath from 'remark-math';
+import rehypeKatex from 'rehype-katex';
+import supersub from 'remark-supersub';
+import ReactMarkdown from 'react-markdown';
+import rehypeHighlight from 'rehype-highlight';
+import { EditIcon, FileText, Check } from 'lucide-react';
+import { Controller, useFormContext } from 'react-hook-form';
+import { TextareaAutosize, Button, TooltipAnchor } from '@librechat/client';
+import type { PluggableList } from 'unified';
+import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
+import VariablesDropdown from './VariablesDropdown';
+import { PromptVariableGfm } from './Markdown';
+import { cn, langSubset } from '~/utils';
+import { useLocalize } from '~/hooks';
+
+type Props = {
+ name: string;
+ isEditing: boolean;
+ setIsEditing: React.Dispatch>;
+};
+
+const PromptEditor: React.FC = ({ name, isEditing, setIsEditing }) => {
+ const localize = useLocalize();
+ const { control } = useFormContext();
+
+ const EditorIcon = useMemo(() => {
+ return isEditing ? Check : EditIcon;
+ }, [isEditing]);
+
+ const rehypePlugins: PluggableList = [
+ [rehypeKatex],
+ [
+ rehypeHighlight,
+ {
+ detect: true,
+ ignoreMissing: true,
+ subset: langSubset,
+ },
+ ],
+ ];
+
+ return (
+
+
{localize('com_ui_control_bar')}
+
+
+
+
+ {localize('com_ui_prompt_text')}
+
+
+
+
+ e.preventDefault()}
+ onClick={() => setIsEditing((prev) => !prev)}
+ aria-label={isEditing ? localize('com_ui_save') : localize('com_ui_edit')}
+ className="size-8 p-0 hover:bg-surface-tertiary"
+ >
+
+
+ }
+ />
+
+
+
+ {!isEditing && (
+
setIsEditing(true)}
+ />
+ )}
+
+ isEditing ? (
+ setIsEditing(false)}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setIsEditing(false);
+ }
+ }}
+ placeholder={localize('com_ui_prompt_input')}
+ aria-label={localize('com_ui_prompt_input')}
+ />
+ ) : (
+ setIsEditing(true)}
+ >
+ {!field.value ? (
+
{localize('com_ui_click_to_edit')}
+ ) : (
+
+ {field.value}
+
+ )}
+
+
+
+
+ {localize('com_ui_click_to_edit')}
+
+
+
+
+ )
+ }
+ />
+
+
+ );
+};
+
+export default memo(PromptEditor);
diff --git a/client/src/components/Prompts/editor/VariablesDropdown.tsx b/client/src/components/Prompts/editor/VariablesDropdown.tsx
new file mode 100644
index 0000000000..04499ee60d
--- /dev/null
+++ b/client/src/components/Prompts/editor/VariablesDropdown.tsx
@@ -0,0 +1,139 @@
+import { useState, useId, useMemo, useCallback } from 'react';
+import * as Menu from '@ariakit/react/menu';
+import { useFormContext } from 'react-hook-form';
+import { DropdownPopup } from '@librechat/client';
+import { specialVariables } from 'librechat-data-provider';
+import { ChevronDown, Check, Sparkles } from 'lucide-react';
+import type { TSpecialVarLabel } from 'librechat-data-provider';
+import { getSpecialVariableIcon } from '~/components/Prompts/utils';
+import { extractUniqueVariables } from '~/utils';
+import { useLiveAnnouncer } from '~/Providers';
+import { useLocalize } from '~/hooks';
+
+const variableKeys = Object.keys(specialVariables) as Array;
+
+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, watch } = methods;
+ const { announcePolite } = useLiveAnnouncer();
+ const promptText = watch(fieldName) || '';
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const usedVariables = useMemo(() => {
+ const vars = extractUniqueVariables(promptText);
+ return new Set(vars.map((v) => v.toLowerCase()));
+ }, [promptText]);
+
+ const handleAddVariable = useCallback(
+ (key: string) => {
+ const currentText = getValues(fieldName) || '';
+ const spacer = currentText.length > 0 ? '\n\n' : '';
+ const labelKey = `com_ui_special_var_${key}` as TSpecialVarLabel;
+ const prefix = localize(labelKey);
+ setValue(fieldName, `${currentText}${spacer}${prefix}: {{${key}}}`, { shouldDirty: true });
+ setIsMenuOpen(false);
+ const announcement = localize('com_ui_special_variable_added', { 0: prefix });
+ announcePolite({ message: announcement, isStatus: true });
+ },
+ [fieldName, getValues, setValue, localize, announcePolite],
+ );
+
+ const items = useMemo(
+ () =>
+ variableKeys.map((key) => {
+ const isUsed = usedVariables.has(key);
+ const Icon = getSpecialVariableIcon(key);
+ const labelKey = `com_ui_special_var_${key}` as TSpecialVarLabel;
+ const descKey = `com_ui_special_var_desc_${key}` as TSpecialVarLabel;
+
+ const iconClass = isUsed
+ ? 'bg-surface-tertiary text-text-tertiary'
+ : 'bg-surface-tertiary text-text-secondary';
+
+ const labelClass = isUsed ? 'text-text-secondary' : 'text-text-primary';
+
+ return {
+ label: localize(labelKey),
+ onClick: () => handleAddVariable(key),
+ disabled: isUsed,
+ icon: ,
+ render: (
+
+
+ {isUsed ? (
+
+ ) : (
+
+ )}
+
+
+
{localize(labelKey)}
+
{localize(descKey)}
+
+
+ ),
+ };
+ }),
+ [usedVariables, localize, handleAddVariable],
+ );
+
+ const usedCount = useMemo(
+ () => variableKeys.filter((key) => usedVariables.has(key)).length,
+ [usedVariables],
+ );
+
+ const buttonClass = isMenuOpen
+ ? 'border-border-heavy bg-surface-tertiary text-text-primary'
+ : 'border-border-medium bg-surface-secondary text-text-primary hover:bg-surface-tertiary';
+
+ return (
+
+
+ );
+}
diff --git a/client/src/components/Prompts/editor/index.ts b/client/src/components/Prompts/editor/index.ts
new file mode 100644
index 0000000000..cd900b18d3
--- /dev/null
+++ b/client/src/components/Prompts/editor/index.ts
@@ -0,0 +1,3 @@
+export { default as PromptEditor } from './PromptEditor';
+export { CodeVariableGfm, PromptVariableGfm } from './Markdown';
+export { default as VariablesDropdown } from './VariablesDropdown';
diff --git a/client/src/components/Prompts/Groups/CategorySelector.tsx b/client/src/components/Prompts/fields/CategorySelector.tsx
similarity index 97%
rename from client/src/components/Prompts/Groups/CategorySelector.tsx
rename to client/src/components/Prompts/fields/CategorySelector.tsx
index 6d45db42ea..ed2386b1c3 100644
--- a/client/src/components/Prompts/Groups/CategorySelector.tsx
+++ b/client/src/components/Prompts/fields/CategorySelector.tsx
@@ -75,12 +75,11 @@ const CategorySelector: React.FC = ({
setIsOpen(!isOpen)}
- aria-label="Prompt's category selector"
- aria-labelledby="category-selector-label"
+ aria-label={t('com_ui_prompt_category_selector_aria')}
>
{'icon' in displayCategory && displayCategory.icon != null && (
diff --git a/client/src/components/Prompts/Command.tsx b/client/src/components/Prompts/fields/Command.tsx
similarity index 88%
rename from client/src/components/Prompts/Command.tsx
rename to client/src/components/Prompts/fields/Command.tsx
index 2948c8393e..902051758a 100644
--- a/client/src/components/Prompts/Command.tsx
+++ b/client/src/components/Prompts/fields/Command.tsx
@@ -67,7 +67,7 @@ const Command = ({
/>
{localize('com_ui_command_placeholder')}
diff --git a/client/src/components/Prompts/Description.tsx b/client/src/components/Prompts/fields/Description.tsx
similarity index 87%
rename from client/src/components/Prompts/Description.tsx
rename to client/src/components/Prompts/fields/Description.tsx
index ea59c8087d..1250a2fa25 100644
--- a/client/src/components/Prompts/Description.tsx
+++ b/client/src/components/Prompts/fields/Description.tsx
@@ -64,7 +64,7 @@ const Description = ({
/>
{localize('com_ui_description_placeholder')}
diff --git a/client/src/components/Prompts/fields/PromptName.tsx b/client/src/components/Prompts/fields/PromptName.tsx
new file mode 100644
index 0000000000..ff08a9bd1f
--- /dev/null
+++ b/client/src/components/Prompts/fields/PromptName.tsx
@@ -0,0 +1,163 @@
+import React, { useEffect, useState, useRef, useCallback } from 'react';
+import { Check, X, Pencil } from 'lucide-react';
+import { Button, Input, Spinner, TooltipAnchor } from '@librechat/client';
+import { useLocalize } from '~/hooks';
+
+type Props = {
+ name?: string;
+ isLoading?: boolean;
+ onSave: (newName: string) => void;
+};
+
+const PromptName: React.FC
= ({ name, isLoading = false, onSave }) => {
+ const localize = useLocalize();
+ const inputRef = useRef(null);
+ const wasLoadingRef = useRef(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [newName, setNewName] = useState(name);
+
+ const handleInputChange = useCallback((e: React.ChangeEvent) => {
+ setNewName(e.target.value);
+ }, []);
+
+ const handleCancel = useCallback(() => {
+ if (isLoading) {
+ return;
+ }
+ setIsEditing(false);
+ setNewName(name);
+ }, [name, isLoading]);
+
+ const saveName = useCallback(() => {
+ if (isLoading) {
+ return;
+ }
+ const savedName = newName?.trim();
+ if (savedName && savedName !== name) {
+ onSave(savedName);
+ } else {
+ setNewName(name);
+ setIsEditing(false);
+ }
+ }, [newName, name, onSave, isLoading]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleCancel();
+ }
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ saveName();
+ }
+ },
+ [handleCancel, saveName],
+ );
+
+ const handleTitleClick = useCallback(() => {
+ setIsEditing(true);
+ }, []);
+
+ const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setIsEditing(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ // Track loading state for detecting save completion
+ useEffect(() => {
+ wasLoadingRef.current = isLoading;
+ }, [isLoading]);
+
+ // Close editing when name updates after save (loading finished)
+ useEffect(() => {
+ setNewName(name);
+ if (wasLoadingRef.current) {
+ setIsEditing(false);
+ wasLoadingRef.current = false;
+ }
+ }, [name]);
+
+ return (
+
+ {isEditing ? (
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+ ) : (
+
+
+ {newName}
+
+
+
+ )}
+
+ );
+};
+
+export default PromptName;
diff --git a/client/src/components/Prompts/fields/index.ts b/client/src/components/Prompts/fields/index.ts
new file mode 100644
index 0000000000..da085dccca
--- /dev/null
+++ b/client/src/components/Prompts/fields/index.ts
@@ -0,0 +1,4 @@
+export { default as Command } from './Command';
+export { default as PromptName } from './PromptName';
+export { default as Description } from './Description';
+export { default as CategorySelector } from './CategorySelector';
diff --git a/client/src/components/Prompts/Groups/CreatePromptForm.tsx b/client/src/components/Prompts/forms/CreatePromptForm.tsx
similarity index 68%
rename from client/src/components/Prompts/Groups/CreatePromptForm.tsx
rename to client/src/components/Prompts/forms/CreatePromptForm.tsx
index 3f94932c68..293fe0235c 100644
--- a/client/src/components/Prompts/Groups/CreatePromptForm.tsx
+++ b/client/src/components/Prompts/forms/CreatePromptForm.tsx
@@ -1,15 +1,16 @@
import { useEffect } from 'react';
+import { FileText } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, TextareaAutosize, Input } from '@librechat/client';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
-import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
-import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
-import PromptVariables from '~/components/Prompts/PromptVariables';
-import Description from '~/components/Prompts/Description';
+import CategorySelector from '../fields/CategorySelector';
+import VariablesDropdown from '../editor/VariablesDropdown';
+import PromptVariables from '../display/PromptVariables';
+import Description from '../fields/Description';
import { usePromptGroupsContext } from '~/Providers';
import { useLocalize, useHasAccess } from '~/hooks';
-import Command from '~/components/Prompts/Command';
+import Command from '../fields/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
@@ -112,19 +113,20 @@ const CreatePromptForm = ({
control={control}
rules={{ required: localize('com_ui_prompt_name_required') }}
render={({ field }) => (
-
-
-
- {localize('com_ui_prompt_text')}*
-
-
-
+
+
+
{errors.prompt ? errors.prompt.message : ' '}
@@ -183,9 +196,18 @@ const CreatePromptForm = ({
{
+ if (!isDirty || isSubmitting || !isValid) {
+ e.preventDefault();
+ }
+ }}
>
{localize('com_ui_create_prompt')}
diff --git a/client/src/components/Prompts/forms/PromptForm.tsx b/client/src/components/Prompts/forms/PromptForm.tsx
new file mode 100644
index 0000000000..ac2334d171
--- /dev/null
+++ b/client/src/components/Prompts/forms/PromptForm.tsx
@@ -0,0 +1,627 @@
+import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
+import debounce from 'lodash/debounce';
+import { useRecoilValue } from 'recoil';
+import { Menu, Rocket } from 'lucide-react';
+import { useParams } from 'react-router-dom';
+import { useForm, FormProvider } from 'react-hook-form';
+import { Button, Skeleton, useToastContext } from '@librechat/client';
+import {
+ Permissions,
+ ResourceType,
+ PermissionBits,
+ PermissionTypes,
+} from 'librechat-data-provider';
+import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
+import {
+ useGetPrompts,
+ useGetPromptGroup,
+ useAddPromptToGroup,
+ useUpdatePromptGroup,
+ useMakePromptProduction,
+} from '~/data-provider';
+import { useResourcePermissions, useHasAccess, useLocalize, useFocusTrap } from '~/hooks';
+import CategorySelector from '../fields/CategorySelector';
+import PromptVariables from '../display/PromptVariables';
+import PromptVersions from '../display/PromptVersions';
+import { usePromptGroupsContext } from '~/Providers';
+import PromptDetails from '../display/PromptDetails';
+import DeletePrompt from '../dialogs/DeletePrompt';
+import NoPromptGroup from '../lists/NoPromptGroup';
+import PromptEditor from '../editor/PromptEditor';
+import SkeletonForm from '../utils/SkeletonForm';
+import Description from '../fields/Description';
+import SharePrompt from '../dialogs/SharePrompt';
+import PromptName from '../fields/PromptName';
+import { cn, findPromptGroup } from '~/utils';
+import { PromptsEditorMode } from '~/common';
+import Command from '../fields/Command';
+import store from '~/store';
+
+interface VersionsPanelProps {
+ group: TPromptGroup;
+ prompts: TPrompt[];
+ selectedPrompt: TPrompt | undefined;
+ selectionIndex: number;
+ isLoadingPrompts: boolean;
+ canEdit: boolean;
+ setSelectionIndex: React.Dispatch
>;
+}
+
+const VersionsPanel = React.memo(
+ ({
+ group,
+ prompts,
+ selectedPrompt,
+ isLoadingPrompts,
+ canEdit,
+ selectionIndex,
+ setSelectionIndex,
+ }: VersionsPanelProps) => {
+ const localize = useLocalize();
+ const makeProductionMutation = useMakePromptProduction();
+
+ const groupId = group?._id || '';
+ const isLoadingGroup = !group;
+ const isProductionVersion = selectedPrompt?._id === group?.productionId;
+
+ return (
+
+ {canEdit && (
+
+ {
+ if (!selectedPrompt) {
+ return;
+ }
+ const { _id: promptVersionId = '', prompt } = selectedPrompt;
+ makeProductionMutation.mutate({
+ id: promptVersionId,
+ groupId,
+ productionPrompt: { prompt },
+ });
+ }}
+ disabled={
+ isLoadingGroup ||
+ !selectedPrompt ||
+ isProductionVersion ||
+ makeProductionMutation.isLoading ||
+ !canEdit
+ }
+ >
+
+
+ {isProductionVersion ? localize('com_ui_production') : localize('com_ui_deploy')}
+
+
+
+ )}
+
+ {isLoadingPrompts &&
+ Array.from({ length: 6 }).map((_, index: number) => (
+
+
+
+ ))}
+ {!isLoadingPrompts && prompts.length > 0 && (
+ <>
+
+
+ {localize('com_ui_versions')}
+
+
+ {prompts.length}
+
+
+
+ >
+ )}
+
+
+ );
+ },
+);
+
+VersionsPanel.displayName = 'VersionsPanel';
+
+interface HeaderActionsProps {
+ group: TPromptGroup;
+ canEdit: boolean;
+ canDelete: boolean;
+ selectedPromptId?: string;
+ onCategoryChange?: (value: string) => void;
+}
+
+const HeaderActions = React.memo(
+ ({ group, canEdit, canDelete, selectedPromptId, onCategoryChange }: HeaderActionsProps) => {
+ const hasShareAccess = useHasAccess({
+ permissionType: PermissionTypes.PROMPTS,
+ permission: Permissions.SHARE,
+ });
+
+ const groupId = group?._id || '';
+ const groupCategory = group?.category || '';
+ const isLoadingGroup = !group;
+
+ return (
+
+
+ {hasShareAccess && }
+ {canDelete && (
+
+ )}
+
+ );
+ },
+);
+
+HeaderActions.displayName = 'HeaderActions';
+
+const PromptForm = () => {
+ const params = useParams();
+ const localize = useLocalize();
+ const { showToast } = useToastContext();
+ const { hasAccess } = usePromptGroupsContext();
+ const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
+ const promptId = params.promptId || '';
+
+ const editorMode = useRecoilValue(store.promptsEditorMode);
+ const [selectionIndex, setSelectionIndex] = useState(0);
+
+ const prevIsEditingRef = useRef(false);
+ const sidePanelRef = useRef(null);
+ const sidePanelTriggerRef = useRef(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [initialLoad, setInitialLoad] = useState(true);
+ const [showSidePanel, setShowSidePanel] = useState(false);
+ const sidePanelWidth = '320px';
+
+ // Reset selection when navigating to a different prompt group
+ useEffect(() => {
+ setSelectionIndex(0);
+ setIsEditing(false);
+ }, [promptId]);
+
+ const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId, {
+ enabled: hasAccess && !!promptId,
+ });
+ const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
+ { groupId: promptId },
+ { enabled: hasAccess && !!promptId },
+ );
+
+ const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
+ ResourceType.PROMPTGROUP,
+ group?._id || '',
+ );
+
+ const canEdit = hasPermission(PermissionBits.EDIT);
+ const canDelete = hasPermission(PermissionBits.DELETE);
+ const canView = hasPermission(PermissionBits.VIEW);
+
+ const methods = useForm({
+ defaultValues: {
+ prompt: '',
+ promptName: group ? group.name : '',
+ category: group ? group.category : '',
+ },
+ });
+ const { handleSubmit, setValue, reset, watch } = methods;
+ const promptText = watch('prompt');
+
+ const selectedPrompt = useMemo(
+ () => (prompts.length > 0 ? prompts[selectionIndex] : undefined),
+ [prompts, selectionIndex],
+ );
+
+ const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
+
+ const { groupsQuery } = usePromptGroupsContext();
+
+ const updateGroupMutation = useUpdatePromptGroup({
+ onError: () => {
+ showToast({
+ status: 'error',
+ message: localize('com_ui_prompt_update_error'),
+ });
+ },
+ });
+
+ const makeProductionMutation = useMakePromptProduction();
+ const addPromptToGroupMutation = useAddPromptToGroup({
+ onMutate: (variables) => {
+ reset(
+ {
+ prompt: variables.prompt.prompt,
+ category: group?.category || '',
+ },
+ { keepDirtyValues: true },
+ );
+ },
+ onSuccess(data) {
+ if (alwaysMakeProd && data.prompt._id != null && data.prompt._id && data.prompt.groupId) {
+ makeProductionMutation.mutate({
+ id: data.prompt._id,
+ groupId: data.prompt.groupId,
+ productionPrompt: { prompt: data.prompt.prompt },
+ });
+ }
+
+ reset({
+ prompt: data.prompt.prompt,
+ promptName: group?.name || '',
+ category: group?.category || '',
+ });
+ },
+ });
+
+ const onSave = useCallback(
+ (value: string) => {
+ if (!canEdit) {
+ return;
+ }
+ if (!value) {
+ // TODO: show toast, cannot be empty.
+ return;
+ }
+ if (!selectedPrompt) {
+ return;
+ }
+
+ const groupId = selectedPrompt.groupId || group?._id;
+ if (!groupId) {
+ return;
+ }
+
+ const tempPrompt: TCreatePrompt = {
+ prompt: {
+ type: selectedPrompt.type ?? 'text',
+ groupId: groupId,
+ prompt: value,
+ },
+ };
+
+ if (value === selectedPrompt.prompt) {
+ return;
+ }
+
+ // We're adding to an existing group, so use the addPromptToGroup mutation
+ addPromptToGroupMutation.mutate({ ...tempPrompt, groupId });
+ },
+ [selectedPrompt, group, addPromptToGroupMutation, canEdit],
+ );
+
+ const handleLoadingComplete = useCallback(() => {
+ if (isLoadingGroup || isLoadingPrompts) {
+ return;
+ }
+ setInitialLoad(false);
+ }, [isLoadingGroup, isLoadingPrompts]);
+
+ const selectedPromptRef = useRef(selectedPrompt);
+ useEffect(() => {
+ selectedPromptRef.current = selectedPrompt;
+ }, [selectedPrompt]);
+
+ useEffect(() => {
+ if (prevIsEditingRef.current && !isEditing && canEdit && selectedPromptRef.current) {
+ handleSubmit((data) => onSave(data.prompt))();
+ }
+ prevIsEditingRef.current = isEditing;
+ }, [isEditing, onSave, handleSubmit, canEdit]);
+
+ useEffect(() => {
+ handleLoadingComplete();
+ }, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]);
+
+ useEffect(() => {
+ setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false });
+ setValue('category', group ? group.category : '', { shouldDirty: false });
+ }, [selectedPrompt, group, setValue]);
+
+ useEffect(() => {
+ const handleResize = () => {
+ if (window.matchMedia('(min-width: 1022px)').matches) {
+ setShowSidePanel(false);
+ }
+ };
+
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ const handleSidePanelEscape = useCallback(() => {
+ setShowSidePanel(false);
+ sidePanelTriggerRef.current?.focus();
+ }, []);
+
+ useFocusTrap(sidePanelRef, showSidePanel, handleSidePanelEscape);
+
+ const debouncedUpdateOneliner = useMemo(
+ () =>
+ debounce(
+ (
+ groupId: string,
+ oneliner: string,
+ mutate: (vars: { id: string; payload: { oneliner: string } }) => void,
+ ) => {
+ mutate({ id: groupId, payload: { oneliner } });
+ },
+ 950,
+ ),
+ [],
+ );
+
+ const debouncedUpdateCommand = useMemo(
+ () =>
+ debounce(
+ (
+ groupId: string,
+ command: string,
+ mutate: (vars: { id: string; payload: { command: string } }) => void,
+ ) => {
+ mutate({ id: groupId, payload: { command } });
+ },
+ 950,
+ ),
+ [],
+ );
+
+ useEffect(() => {
+ return () => {
+ debouncedUpdateOneliner.cancel();
+ debouncedUpdateCommand.cancel();
+ };
+ }, [debouncedUpdateOneliner, debouncedUpdateCommand]);
+
+ const handleUpdateOneliner = useCallback(
+ (oneliner: string) => {
+ if (!group || !group._id) {
+ return;
+ }
+ debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate);
+ },
+ [group, updateGroupMutation.mutate, debouncedUpdateOneliner],
+ );
+
+ const handleUpdateCommand = useCallback(
+ (command: string) => {
+ if (!group || !group._id) {
+ return;
+ }
+ debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate);
+ },
+ [group, updateGroupMutation.mutate, debouncedUpdateCommand],
+ );
+
+ const handleCategoryChange = useCallback(
+ (value: string) => {
+ if (!group?._id) {
+ return;
+ }
+ updateGroupMutation.mutate({
+ id: group._id,
+ payload: { name: group.name, category: value },
+ });
+ },
+ [group?._id, group?.name, updateGroupMutation],
+ );
+
+ if (initialLoad) {
+ return ;
+ }
+
+ // Show read-only view if user doesn't have edit permission
+ if (!canEdit && !permissionsLoading && groupsQuery.data) {
+ const fetchedPrompt = findPromptGroup(
+ groupsQuery.data,
+ (group) => group._id === params.promptId,
+ );
+ if (!fetchedPrompt && !canView) {
+ return ;
+ }
+
+ if (fetchedPrompt || group) {
+ return ;
+ }
+ }
+
+ if (!group || group._id == null) {
+ return null;
+ }
+
+ const groupName = group.name;
+
+ return (
+
+
+
+ );
+};
+
+export default PromptForm;
diff --git a/client/src/components/Prompts/PreviewLabels.tsx b/client/src/components/Prompts/forms/PromptLabelsForm.tsx
similarity index 64%
rename from client/src/components/Prompts/PreviewLabels.tsx
rename to client/src/components/Prompts/forms/PromptLabelsForm.tsx
index 6baa1d186d..f30fa0da3a 100644
--- a/client/src/components/Prompts/PreviewLabels.tsx
+++ b/client/src/components/Prompts/forms/PromptLabelsForm.tsx
@@ -3,8 +3,10 @@ import { Input } from '@librechat/client';
import { Cross1Icon } from '@radix-ui/react-icons';
import type { TPrompt } from 'librechat-data-provider';
import { useUpdatePromptLabels } from '~/data-provider';
+import { useLocalize } from '~/hooks';
-const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
+const PromptLabelsForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
+ const localize = useLocalize();
const [labelInput, setLabelInput] = useState('');
const [labels, setLabels] = useState([]);
const updatePromptLabelsMutation = useUpdatePromptLabels();
@@ -13,7 +15,7 @@ const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
setLabelInput(e.target.value);
};
- const handleKeyPress = (e: React.KeyboardEvent) => {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && labelInput.trim()) {
const newLabels = [...labels, labelInput.trim()];
setLabels(newLabels);
@@ -30,22 +32,25 @@ const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
- Labels
-
+
+ {localize('com_ui_labels')}
+
+
{labels.length ? (
labels.map((label, index) => (
-
{label}
- {
const newLabels = labels.filter((l) => l !== label);
setLabels(newLabels);
@@ -55,15 +60,18 @@ const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
});
}}
className="cursor-pointer"
- />
-
+ aria-label={`${localize('com_ui_delete')} ${label}`}
+ >
+
+
+
))
) : (
- No Labels
+ {localize('com_ui_no_labels')}
)}
>
);
};
-export default PromptForm;
+export default PromptLabelsForm;
diff --git a/client/src/components/Prompts/Groups/VariableForm.tsx b/client/src/components/Prompts/forms/VariableForm.tsx
similarity index 96%
rename from client/src/components/Prompts/Groups/VariableForm.tsx
rename to client/src/components/Prompts/forms/VariableForm.tsx
index f1f31162f4..cb3ff6af7f 100644
--- a/client/src/components/Prompts/Groups/VariableForm.tsx
+++ b/client/src/components/Prompts/forms/VariableForm.tsx
@@ -12,7 +12,8 @@ import type { TPromptGroup } from 'librechat-data-provider';
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
import { cn, wrapVariable, defaultTextProps, extractVariableInfo } from '~/utils';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
-import { PromptVariableGfm } from '../Markdown';
+import { useRecordPromptUsage } from '~/data-provider';
+import { PromptVariableGfm } from '../editor/Markdown';
type FieldType = 'text' | 'select';
@@ -82,6 +83,7 @@ export default function VariableForm({
);
const { submitPrompt } = useSubmitMessage();
+ const recordUsage = useRecordPromptUsage();
const { control, handleSubmit } = useForm
({
defaultValues: {
fields: uniqueVariables.map((variable) => ({
@@ -134,6 +136,9 @@ export default function VariableForm({
});
submitPrompt(text);
+ if (group._id) {
+ recordUsage.mutate(group._id);
+ }
onClose();
};
@@ -176,6 +181,7 @@ export default function VariableForm({
value={value}
onChange={onChange}
onBlur={onBlur}
+ aria-label={field.config.variable}
/>
);
}
diff --git a/client/src/components/Prompts/forms/index.ts b/client/src/components/Prompts/forms/index.ts
new file mode 100644
index 0000000000..30e23acdfa
--- /dev/null
+++ b/client/src/components/Prompts/forms/index.ts
@@ -0,0 +1,4 @@
+export { default as PromptForm } from './PromptForm';
+export { default as VariableForm } from './VariableForm';
+export { default as PromptLabelsForm } from './PromptLabelsForm';
+export { default as CreatePromptForm } from './CreatePromptForm';
diff --git a/client/src/components/Prompts/index.ts b/client/src/components/Prompts/index.ts
index 3dbb515484..067e42fd35 100644
--- a/client/src/components/Prompts/index.ts
+++ b/client/src/components/Prompts/index.ts
@@ -1,10 +1,29 @@
-export { default as PromptName } from './PromptName';
-export { default as PromptsView } from './PromptsView';
-export { default as PromptEditor } from './PromptEditor';
-export { default as PromptForm } from './PromptForm';
-export { default as PreviewLabels } from './PreviewLabels';
-export { default as PromptGroupsList } from './Groups/List';
-export { default as DashGroupItem } from './Groups/DashGroupItem';
-export { default as EmptyPromptPreview } from './EmptyPromptPreview';
-export { default as PromptSidePanel } from './Groups/GroupSidePanel';
-export { default as CreatePromptForm } from './Groups/CreatePromptForm';
+export { PromptsView } from './layouts';
+export { CategoryIcon, SkeletonForm } from './utils';
+export { PromptName, Command, Description, CategorySelector } from './fields';
+export { PreviewPrompt, DeleteVersion, VariableDialog, SharePrompt } from './dialogs';
+export { PromptForm, CreatePromptForm, VariableForm, PromptLabelsForm } from './forms';
+export { PromptEditor, VariablesDropdown, CodeVariableGfm, PromptVariableGfm } from './editor';
+export { PromptDetails, PromptVariables, PromptVersions, EmptyPromptPreview } from './display';
+export {
+ GroupSidePanel as PromptSidePanel,
+ PromptsAccordion,
+ FilterPrompts,
+ PanelNavigation,
+} from './sidebar';
+export {
+ List as PromptGroupsList,
+ DashGroupItem,
+ ChatGroupItem,
+ ListCard,
+ NoPromptGroup,
+} from './lists';
+export {
+ CreatePromptButton,
+ AdminSettings,
+ AdvancedSwitch,
+ AlwaysMakeProd,
+ AutoSendPrompt,
+ BackToChat,
+ ManagePrompts,
+} from './buttons';
diff --git a/client/src/components/Prompts/PromptsView.tsx b/client/src/components/Prompts/layouts/PromptsView.tsx
similarity index 64%
rename from client/src/components/Prompts/PromptsView.tsx
rename to client/src/components/Prompts/layouts/PromptsView.tsx
index f390ffddd3..6e7c1fef76 100644
--- a/client/src/components/Prompts/PromptsView.tsx
+++ b/client/src/components/Prompts/layouts/PromptsView.tsx
@@ -1,23 +1,33 @@
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
-import { Outlet, useParams, useNavigate } from 'react-router-dom';
-import { PermissionTypes, Permissions } from 'librechat-data-provider';
-import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
+import { Sidebar, useMediaQuery } from '@librechat/client';
+import { Outlet, useParams, useNavigate, useLocation } from 'react-router-dom';
+import { PermissionTypes, Permissions, SystemRoles } from 'librechat-data-provider';
+import { AdvancedSwitch, AdminSettings } from '~/components/Prompts';
+import { useHasAccess, useLocalize, useAuthContext } from '~/hooks';
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
-import GroupSidePanel from './Groups/GroupSidePanel';
-import { useHasAccess, useLocalize } from '~/hooks';
+import GroupSidePanel from '../sidebar/GroupSidePanel';
+import FilterPrompts from '../sidebar/FilterPrompts';
import { PromptGroupsProvider } from '~/Providers';
-import { useMediaQuery } from '@librechat/client';
import { cn } from '~/utils';
+const promptsPathPattern = /prompts\/(?!new(?:\/|$)).*$/;
+
export default function PromptsView() {
const params = useParams();
+ const location = useLocation();
const navigate = useNavigate();
+ const localize = useLocalize();
+ const { user } = useAuthContext();
+
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
const isSmallerScreen = useMediaQuery('(max-width: 768px)');
const [panelVisible, setPanelVisible] = useState(!isSmallerScreen);
const openPanelRef = useRef(null);
const closePanelRef = useRef(null);
- const localize = useLocalize();
+ const isPromptsPath = useMemo(
+ () => promptsPathPattern.test(location.pathname),
+ [location.pathname],
+ );
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
@@ -63,11 +73,27 @@ export default function PromptsView() {
return (
-
+ {isSmallerScreen && isDetailView ? (
+
+
+
+
+
+ {isPromptsPath &&
}
+ {user?.role === SystemRoles.ADMIN &&
}
+
+
+ ) : (
+
+ )}
{isSmallerScreen && panelVisible && isDetailView && (
-
+
@@ -101,7 +127,7 @@ export default function PromptsView() {
diff --git a/client/src/components/Prompts/layouts/index.ts b/client/src/components/Prompts/layouts/index.ts
new file mode 100644
index 0000000000..15a6c3565c
--- /dev/null
+++ b/client/src/components/Prompts/layouts/index.ts
@@ -0,0 +1 @@
+export { default as PromptsView } from './PromptsView';
diff --git a/client/src/components/Prompts/lists/ChatGroupItem.tsx b/client/src/components/Prompts/lists/ChatGroupItem.tsx
new file mode 100644
index 0000000000..3a4374ee7a
--- /dev/null
+++ b/client/src/components/Prompts/lists/ChatGroupItem.tsx
@@ -0,0 +1,153 @@
+import { useState, memo, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button, TooltipAnchor } from '@librechat/client';
+import { Eye, Pencil, EarthIcon, User } from 'lucide-react';
+import { PermissionBits, ResourceType } from 'librechat-data-provider';
+import type { TPromptGroup } from 'librechat-data-provider';
+import { useLocalize, useAuthContext, useSubmitMessage, useResourcePermissions } from '~/hooks';
+import { useRecordPromptUsage } from '~/data-provider';
+import VariableDialog from '../dialogs/VariableDialog';
+import PreviewPrompt from '../dialogs/PreviewPrompt';
+import { detectVariables } from '~/utils';
+import ListCard from './ListCard';
+
+function ChatGroupItem({ group }: { group: TPromptGroup }) {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const { user } = useAuthContext();
+ const { submitPrompt } = useSubmitMessage();
+ const recordUsage = useRecordPromptUsage();
+
+ const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
+ const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
+
+ const groupIsGlobal = group.isPublic === true;
+
+ // Check permissions for the promptGroup
+ const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
+ const canEdit = hasPermission(PermissionBits.EDIT);
+
+ const previewButtonRef = useRef
(null);
+
+ const onCardClick = () => {
+ const text = group.productionPrompt?.prompt;
+ if (!text?.trim()) {
+ return;
+ }
+
+ if (detectVariables(text)) {
+ setVariableDialogOpen(true);
+ return;
+ }
+
+ submitPrompt(text);
+ if (group._id) {
+ recordUsage.mutate(group._id);
+ }
+ };
+
+ return (
+ <>
+
+
0
+ ? group.oneliner
+ : (group.productionPrompt?.prompt ?? '')
+ }
+ icon={
+ isSharedPrompt || groupIsGlobal ? (
+ <>
+ {isSharedPrompt && (
+
+
+
+ }
+ />
+ )}
+ {groupIsGlobal && (
+
+ )}
+ >
+ ) : undefined
+ }
+ >
+
+
{
+ e.stopPropagation();
+ setPreviewDialogOpen(true);
+ }}
+ >
+
+
+ }
+ />
+ {canEdit && (
+ {
+ e.stopPropagation();
+ navigate(`/d/prompts/${group._id}`);
+ }}
+ >
+
+
+ }
+ />
+ )}
+
+
+
+ {
+ requestAnimationFrame(() => {
+ previewButtonRef.current?.focus({ preventScroll: true });
+ });
+ }}
+ />
+ setVariableDialogOpen(false)}
+ group={group}
+ />
+ >
+ );
+}
+
+export default memo(ChatGroupItem);
diff --git a/client/src/components/Prompts/lists/DashGroupItem.tsx b/client/src/components/Prompts/lists/DashGroupItem.tsx
new file mode 100644
index 0000000000..be79ea7945
--- /dev/null
+++ b/client/src/components/Prompts/lists/DashGroupItem.tsx
@@ -0,0 +1,223 @@
+import { memo, useState, useCallback, useEffect, useRef } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { EarthIcon, Pencil, Trash2, User } from 'lucide-react';
+import { PermissionBits, ResourceType, type TPromptGroup } from 'librechat-data-provider';
+import {
+ Input,
+ Label,
+ Button,
+ Spinner,
+ OGDialog,
+ TooltipAnchor,
+ OGDialogTrigger,
+ OGDialogTemplate,
+ useToastContext,
+} from '@librechat/client';
+import { useLocalize, useAuthContext, useResourcePermissions } from '~/hooks';
+import { useLiveAnnouncer } from '~/Providers';
+import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
+import CategoryIcon from '../utils/CategoryIcon';
+import { cn } from '~/utils';
+
+function DashGroupItemComponent({ group }: { group: TPromptGroup }) {
+ const params = useParams();
+ const navigate = useNavigate();
+ const localize = useLocalize();
+ const { user } = useAuthContext();
+
+ const isSharedPrompt = group.author !== user?.id && Boolean(group.authorName);
+
+ const { showToast } = useToastContext();
+ const { announcePolite } = useLiveAnnouncer();
+ const [nameInputValue, setNameInputValue] = useState(group.name);
+ const [renameOpen, setRenameOpen] = useState(false);
+
+ useEffect(() => {
+ if (!renameOpen) {
+ setNameInputValue(group.name);
+ }
+ }, [group.name, renameOpen]);
+
+ const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || '');
+ const canEdit = hasPermission(PermissionBits.EDIT);
+ const canDelete = hasPermission(PermissionBits.DELETE);
+
+ const isGlobalGroup = group.isPublic === true;
+
+ const updateGroup = useUpdatePromptGroup({
+ onSuccess: () => {
+ setRenameOpen(false);
+ showToast({ status: 'success', message: localize('com_ui_prompt_renamed') });
+ announcePolite({ message: localize('com_ui_prompt_renamed'), isStatus: true });
+ },
+ onError: () => {
+ showToast({ status: 'error', message: localize('com_ui_prompt_update_error') });
+ },
+ });
+
+ const deleteGroup = useDeletePromptGroup({
+ onSuccess: (_response, variables) => {
+ announcePolite({
+ message: localize('com_ui_prompt_deleted_group', { 0: group.name }),
+ isStatus: true,
+ });
+ if (variables.id === group._id) {
+ navigate('/d/prompts');
+ }
+ },
+ });
+
+ const { isLoading: isSaving } = updateGroup;
+ const isDeleting = deleteGroup.isLoading;
+
+ const updateGroupRef = useRef(updateGroup);
+ updateGroupRef.current = updateGroup;
+ const deleteGroupRef = useRef(deleteGroup);
+ deleteGroupRef.current = deleteGroup;
+
+ const handleSaveRename = useCallback(() => {
+ updateGroupRef.current.mutate({ id: group._id ?? '', payload: { name: nameInputValue } });
+ }, [group._id, nameInputValue]);
+
+ const handleDelete = useCallback(() => {
+ deleteGroupRef.current.mutate({ id: group._id ?? '' });
+ }, [group._id]);
+
+ const handleContainerClick = useCallback(() => {
+ navigate(`/d/prompts/${group._id}`, { replace: true });
+ }, [group._id, navigate]);
+
+ const ariaLabel = group.category
+ ? localize('com_ui_prompt_group_button', {
+ name: group.name,
+ category: group.category,
+ })
+ : localize('com_ui_prompt_group_button_no_category', {
+ name: group.name,
+ });
+
+ return (
+
+
+
+
+ {canEdit && (
+
+
+
+
+
+ }
+ />
+
+ setNameInputValue(e.target.value)}
+ className="w-full"
+ aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
+ />
+ }
+ selection={
+
+ {isSaving ? : localize('com_ui_save')}
+
+ }
+ />
+
+ )}
+
+ {canDelete && (
+
+
+
+
+
+ }
+ />
+
+ {localize('com_ui_prompt_delete_confirm', { 0: group.name })}}
+ selection={
+
+ {isDeleting ? : localize('com_ui_delete')}
+
+ }
+ />
+
+ )}
+
+
+ );
+}
+
+export default memo(DashGroupItemComponent);
diff --git a/client/src/components/Prompts/lists/List.tsx b/client/src/components/Prompts/lists/List.tsx
new file mode 100644
index 0000000000..c7962d1a92
--- /dev/null
+++ b/client/src/components/Prompts/lists/List.tsx
@@ -0,0 +1,65 @@
+import { FileText } from 'lucide-react';
+import { Skeleton } from '@librechat/client';
+import type { TPromptGroup } from 'librechat-data-provider';
+import DashGroupItem from './DashGroupItem';
+import ChatGroupItem from './ChatGroupItem';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+export default function List({
+ groups = [],
+ isChatRoute,
+ isLoading,
+}: {
+ groups?: TPromptGroup[];
+ isChatRoute: boolean;
+ isLoading: boolean;
+}) {
+ const localize = useLocalize();
+
+ return (
+
+
+
+ {isLoading && isChatRoute && (
+
+ )}
+ {isLoading && !isChatRoute && (
+
+ {Array.from({ length: 10 }).map((_, index: number) => (
+
+ ))}
+
+ )}
+ {!isLoading && groups.length === 0 && (
+
+
+
+
+
+ {localize('com_ui_no_prompts_title')}
+
+
+ {localize('com_ui_add_first_prompt')}
+
+
+ )}
+ {isChatRoute ? (
+ groups.map((group) =>
)
+ ) : (
+
+ {groups.map((group) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/client/src/components/Prompts/lists/ListCard.tsx b/client/src/components/Prompts/lists/ListCard.tsx
new file mode 100644
index 0000000000..5fa5ff67d2
--- /dev/null
+++ b/client/src/components/Prompts/lists/ListCard.tsx
@@ -0,0 +1,63 @@
+import React, { useId } from 'react';
+import { Label } from '@librechat/client';
+import CategoryIcon from '../utils/CategoryIcon';
+import { useLocalize } from '~/hooks';
+
+export default function ListCard({
+ category,
+ name,
+ snippet,
+ onClick,
+ children,
+ icon,
+}: {
+ category: string;
+ name: string;
+ snippet: string;
+ onClick?: () => void;
+ children?: React.ReactNode;
+ icon?: React.ReactNode;
+}) {
+ const id = useId();
+ const localize = useLocalize();
+ const snippetId = `${id}-snippet`;
+ const titleId = `${id}-title`;
+
+ const ariaLabel = category
+ ? localize('com_ui_prompt_group_button', { name, category })
+ : localize('com_ui_prompt_group_button_no_category', { name });
+
+ return (
+
+ {onClick && (
+
+ )}
+
+
+
+
+ {name}
+
+ {icon}
+
+
{children}
+
+
+ {snippet}
+
+
+ );
+}
diff --git a/client/src/components/Prompts/Groups/NoPromptGroup.tsx b/client/src/components/Prompts/lists/NoPromptGroup.tsx
similarity index 84%
rename from client/src/components/Prompts/Groups/NoPromptGroup.tsx
rename to client/src/components/Prompts/lists/NoPromptGroup.tsx
index 58a5e4816a..88855c11b2 100644
--- a/client/src/components/Prompts/Groups/NoPromptGroup.tsx
+++ b/client/src/components/Prompts/lists/NoPromptGroup.tsx
@@ -8,8 +8,8 @@ export default function NoPromptGroup() {
return (
-
-
+
+
{localize('com_ui_prompt_preview_not_shared')}
+
{searchResultsAnnouncement}
@@ -100,7 +103,7 @@ export default function FilterPrompts({
value={categoryFilter || SystemCategories.ALL}
onChange={onSelect}
options={filterOptions}
- className={cn('rounded-lg bg-transparent', dropdownClassName)}
+ className={cn('shrink-0 rounded-lg bg-transparent [&>button]:size-9', dropdownClassName)}
icon={
}
label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')}
@@ -113,6 +116,7 @@ export default function FilterPrompts({
onChange={handleSearchChange}
containerClassName="flex-1"
/>
+
);
}
diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/sidebar/GroupSidePanel.tsx
similarity index 52%
rename from client/src/components/Prompts/Groups/GroupSidePanel.tsx
rename to client/src/components/Prompts/sidebar/GroupSidePanel.tsx
index 1eca604af1..a5065c69ac 100644
--- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx
+++ b/client/src/components/Prompts/sidebar/GroupSidePanel.tsx
@@ -1,12 +1,15 @@
-import { useMemo } from 'react';
+import { useMemo, useCallback } from 'react';
+import { ArrowLeft } from 'lucide-react';
+import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { Button, Sidebar, TooltipAnchor } from '@librechat/client';
-import ManagePrompts from '~/components/Prompts/ManagePrompts';
-import { usePromptGroupsContext } from '~/Providers';
-import List from '~/components/Prompts/Groups/List';
+import { usePromptGroupsContext, useDashboardContext } from '~/Providers';
+import { useLocalize, useCustomLink } from '~/hooks';
+import ManagePrompts from '../buttons/ManagePrompts';
import PanelNavigation from './PanelNavigation';
-import { useLocalize } from '~/hooks';
+import List from '../lists/List';
import { cn } from '~/utils';
+import store from '~/store';
export default function GroupSidePanel({
children,
@@ -23,19 +26,53 @@ export default function GroupSidePanel({
const localize = useLocalize();
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
+ const { prevLocationPath } = useDashboardContext();
+ const setPromptsName = useSetRecoilState(store.promptsName);
+ const setPromptsCategory = useSetRecoilState(store.promptsCategory);
+ const clickCallback = useCallback(() => {
+ setPromptsName('');
+ setPromptsCategory('');
+ }, [setPromptsName, setPromptsCategory]);
+ const lastConversationId = useMemo(() => {
+ if (!prevLocationPath || prevLocationPath.includes('/d/')) {
+ return 'new';
+ }
+ const parts = prevLocationPath.split('/');
+ return parts[parts.length - 1];
+ }, [prevLocationPath]);
+ const chatLinkHandler = useCustomLink('/c/' + lastConversationId, clickCallback);
+ const promptsLinkHandler = useCustomLink('/d/prompts');
+
const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } =
usePromptGroupsContext();
return (
{onClose && (
)}
-
+
{children}
}
{children}
-
+
{localize('com_ui_next')}
-
+
);
}
diff --git a/client/src/components/Prompts/sidebar/PromptsAccordion.tsx b/client/src/components/Prompts/sidebar/PromptsAccordion.tsx
new file mode 100644
index 0000000000..aad928c800
--- /dev/null
+++ b/client/src/components/Prompts/sidebar/PromptsAccordion.tsx
@@ -0,0 +1,21 @@
+import { usePromptGroupsContext } from '~/Providers';
+import AutoSendPrompt from '../buttons/AutoSendPrompt';
+import PromptSidePanel from './GroupSidePanel';
+import FilterPrompts from './FilterPrompts';
+
+export default function PromptsAccordion() {
+ const groupsNav = usePromptGroupsContext();
+ return (
+
+ );
+}
diff --git a/client/src/components/Prompts/sidebar/index.ts b/client/src/components/Prompts/sidebar/index.ts
new file mode 100644
index 0000000000..3ef99e291b
--- /dev/null
+++ b/client/src/components/Prompts/sidebar/index.ts
@@ -0,0 +1,4 @@
+export { default as FilterPrompts } from './FilterPrompts';
+export { default as GroupSidePanel } from './GroupSidePanel';
+export { default as PanelNavigation } from './PanelNavigation';
+export { default as PromptsAccordion } from './PromptsAccordion';
diff --git a/client/src/components/Prompts/Groups/CategoryIcon.tsx b/client/src/components/Prompts/utils/CategoryIcon.tsx
similarity index 94%
rename from client/src/components/Prompts/Groups/CategoryIcon.tsx
rename to client/src/components/Prompts/utils/CategoryIcon.tsx
index 064a5bd3dd..ddbc564ddb 100644
--- a/client/src/components/Prompts/Groups/CategoryIcon.tsx
+++ b/client/src/components/Prompts/utils/CategoryIcon.tsx
@@ -66,5 +66,5 @@ export default function CategoryIcon({
if (!IconComponent) {
return null;
}
- return
;
+ return
;
}
diff --git a/client/src/components/Prompts/SkeletonForm.tsx b/client/src/components/Prompts/utils/SkeletonForm.tsx
similarity index 93%
rename from client/src/components/Prompts/SkeletonForm.tsx
rename to client/src/components/Prompts/utils/SkeletonForm.tsx
index fbaf17f3a1..ea6248947d 100644
--- a/client/src/components/Prompts/SkeletonForm.tsx
+++ b/client/src/components/Prompts/utils/SkeletonForm.tsx
@@ -3,7 +3,7 @@ import { Skeleton } from '@librechat/client';
export default function SkeletonForm() {
return (
-
+
diff --git a/client/src/components/Prompts/utils/index.ts b/client/src/components/Prompts/utils/index.ts
new file mode 100644
index 0000000000..cced2f49e3
--- /dev/null
+++ b/client/src/components/Prompts/utils/index.ts
@@ -0,0 +1,3 @@
+export { default as CategoryIcon } from './CategoryIcon';
+export { default as SkeletonForm } from './SkeletonForm';
+export { specialVariableIcons, getSpecialVariableIcon } from './specialVariables';
diff --git a/client/src/components/Prompts/utils/specialVariables.ts b/client/src/components/Prompts/utils/specialVariables.ts
new file mode 100644
index 0000000000..e971903820
--- /dev/null
+++ b/client/src/components/Prompts/utils/specialVariables.ts
@@ -0,0 +1,17 @@
+import { Calendar, User, Clock, Globe, Sparkles } from 'lucide-react';
+import type { specialVariables } from 'librechat-data-provider';
+
+type SpecialVariableKey = keyof typeof specialVariables;
+
+export const specialVariableIcons: Record<
+ SpecialVariableKey,
+ React.ComponentType<{ className?: string }>
+> = {
+ current_date: Calendar,
+ current_datetime: Clock,
+ current_user: User,
+ iso_datetime: Globe,
+};
+
+export const getSpecialVariableIcon = (name: string) =>
+ specialVariableIcons[name as SpecialVariableKey] ?? Sparkles;
diff --git a/client/src/components/SidePanel/Memories/MemoryPanel.tsx b/client/src/components/SidePanel/Memories/MemoryPanel.tsx
index 3a48753fce..e25228fd3f 100644
--- a/client/src/components/SidePanel/Memories/MemoryPanel.tsx
+++ b/client/src/components/SidePanel/Memories/MemoryPanel.tsx
@@ -4,7 +4,7 @@ import { matchSorter } from 'match-sorter';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Button,
- Switch,
+ Checkbox,
Spinner,
FilterInput,
TooltipAnchor,
@@ -169,15 +169,23 @@ export default function MemoryPanel() {
{/* Memory Toggle */}
{hasOptOutAccess && (
-
- {localize('com_ui_use_memory')}
- handleMemoryToggle(!referenceSavedMemories)}
+ aria-label={localize('com_ui_use_memory')}
+ aria-pressed={referenceSavedMemories}
+ disabled={updateMemoryPreferencesMutation.isLoading}
+ >
+
-
+ {localize('com_ui_use_memory')}
+
)}
)}
diff --git a/client/src/components/ui/AdminSettingsDialog.tsx b/client/src/components/ui/AdminSettingsDialog.tsx
index 095e6b6ca8..321f88b06b 100644
--- a/client/src/components/ui/AdminSettingsDialog.tsx
+++ b/client/src/components/ui/AdminSettingsDialog.tsx
@@ -189,82 +189,81 @@ const AdminSettingsDialog: React.FC
= ({
{localize('com_ui_admin_settings_section', { section: localize(sectionKey) })}
-
- {/* Role selection dropdown */}
-
- {localize('com_ui_role_select')}:
-
- {selectedRole}
-
- }
- items={roleDropdownItems}
- itemClassName="items-center justify-center"
- sameWidth={true}
- />
-
- {/* Permissions form */}
-
+ {/* Role selection dropdown */}
+
+ {localize('com_ui_role_select')}:
+
+ {selectedRole}
+
+ }
+ items={roleDropdownItems}
+ itemClassName="items-center justify-center"
+ sameWidth={true}
+ />
+ {/* Permissions form */}
+
{extraContent}
diff --git a/client/src/data-provider/prompts.ts b/client/src/data-provider/prompts.ts
index be98e1f4a2..fbe1753fca 100644
--- a/client/src/data-provider/prompts.ts
+++ b/client/src/data-provider/prompts.ts
@@ -9,6 +9,7 @@ import {
addPromptGroup,
updateGroupInAll,
updateGroupFields,
+ updateGroupFieldsInPlace,
deletePromptGroup,
removeGroupFromAll,
} from '~/utils';
@@ -71,7 +72,7 @@ export const useUpdatePromptGroup = (
},
onError: (err, variables, context) => {
if (context?.group) {
- queryClient.setQueryData([QueryKeys.promptGroups, variables.id], context.group);
+ queryClient.setQueryData([QueryKeys.promptGroup, variables.id], context.group);
}
if (context?.previousListData) {
queryClient.setQueryData
(
@@ -203,9 +204,9 @@ export const useDeletePrompt = (
return data;
}
if (data.productionId === variables._id) {
- data.productionId = prompts[0]._id;
- data.productionPrompt = prompts[0];
+ return { ...data, productionId: prompts[0]?._id, productionPrompt: prompts[0] };
}
+ return data;
},
);
return prompts;
@@ -289,38 +290,40 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions)
mutationFn: (variables: t.TMakePromptProductionRequest) =>
dataService.makePromptProduction(variables.id),
onMutate: (variables: t.TMakePromptProductionRequest) => {
- const group = JSON.parse(
- JSON.stringify(
- queryClient.getQueryData([QueryKeys.promptGroup, variables.groupId]),
- ),
- ) as t.TPromptGroup;
- const groupData = queryClient.getQueryData([
+ const groupData = queryClient.getQueryData([
+ QueryKeys.promptGroup,
+ variables.groupId,
+ ]);
+ const group = groupData ? structuredClone(groupData) : undefined;
+
+ const listData = queryClient.getQueryData([
QueryKeys.promptGroups,
name,
category,
pageSize,
]);
- const previousListData = JSON.parse(JSON.stringify(groupData)) as t.PromptGroupListData;
+ const previousListData = listData ? structuredClone(listData) : undefined;
- if (groupData) {
- const newData = updateGroupFields(
- /* Paginated Data */
- groupData,
- /* Update */
- {
- _id: variables.groupId,
- productionId: variables.id,
- productionPrompt: variables.productionPrompt,
- },
- /* Callback */
- (group) => queryClient.setQueryData([QueryKeys.promptGroup, variables.groupId], group),
- );
+ if (listData) {
+ const newData = updateGroupFieldsInPlace(listData, {
+ _id: variables.groupId,
+ productionId: variables.id,
+ productionPrompt: variables.productionPrompt,
+ });
queryClient.setQueryData(
[QueryKeys.promptGroups, name, category, pageSize],
newData,
);
}
+ if (groupData) {
+ queryClient.setQueryData([QueryKeys.promptGroup, variables.groupId], {
+ ...groupData,
+ productionId: variables.id,
+ productionPrompt: variables.productionPrompt,
+ });
+ }
+
if (onMutate) {
onMutate(variables);
}
@@ -329,7 +332,7 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions)
},
onError: (err, variables, context) => {
if (context?.group) {
- queryClient.setQueryData([QueryKeys.promptGroups, variables.groupId], context.group);
+ queryClient.setQueryData([QueryKeys.promptGroup, variables.groupId], context.group);
}
if (context?.previousListData) {
queryClient.setQueryData(
@@ -353,3 +356,27 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions)
},
});
};
+
+export const useRecordPromptUsage = (): UseMutationResult<
+ { numberOfGenerations: number },
+ unknown,
+ string,
+ unknown
+> => {
+ const queryClient = useQueryClient();
+ const name = useRecoilValue(store.promptsName);
+ const category = useRecoilValue(store.promptsCategory);
+ const pageSize = useRecoilValue(store.promptsPageSize);
+
+ return useMutation({
+ mutationFn: (groupId: string) => dataService.recordPromptGroupUsage(groupId),
+ onSuccess: (data, groupId) => {
+ const update = { _id: groupId, numberOfGenerations: data.numberOfGenerations };
+ queryClient.setQueryData(
+ [QueryKeys.promptGroups, name, category, pageSize],
+ (old) => (old ? updateGroupFieldsInPlace(old, update) : old),
+ );
+ updateGroupInAll(queryClient, update);
+ },
+ });
+};
diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts
index 07cc72dfcf..0f96c3709b 100644
--- a/client/src/hooks/Nav/useSideNavLinks.ts
+++ b/client/src/hooks/Nav/useSideNavLinks.ts
@@ -10,16 +10,16 @@ import {
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TInterfaceConfig, TEndpointsConfig } from 'librechat-data-provider';
-import MCPBuilderPanel from '~/components/SidePanel/MCPBuilder/MCPBuilderPanel';
import type { NavLink } from '~/common';
+import MCPBuilderPanel from '~/components/SidePanel/MCPBuilder/MCPBuilderPanel';
import AgentPanelSwitch from '~/components/SidePanel/Agents/AgentPanelSwitch';
import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel';
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
-import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
import Parameters from '~/components/SidePanel/Parameters/Panel';
import { MemoryPanel } from '~/components/SidePanel/Memories';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import { useHasAccess, useMCPServerManager } from '~/hooks';
+import { PromptsAccordion } from '~/components/Prompts';
export default function useSideNavLinks({
hidePanel,
diff --git a/client/src/hooks/Prompts/useCategories.tsx b/client/src/hooks/Prompts/useCategories.tsx
index b9e1168807..daa4f47e9f 100644
--- a/client/src/hooks/Prompts/useCategories.tsx
+++ b/client/src/hooks/Prompts/useCategories.tsx
@@ -1,5 +1,5 @@
-import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useLocalize, TranslationKeys } from '~/hooks';
+import { CategoryIcon } from '~/components/Prompts';
import { useGetCategories } from '~/data-provider';
const loadingCategories: { label: TranslationKeys; value: string }[] = [
diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts
index 62682b84d8..4b58e434c2 100644
--- a/client/src/hooks/index.ts
+++ b/client/src/hooks/index.ts
@@ -26,6 +26,7 @@ export type { TranslationKeys } from './useLocalize';
export { default as useTimeout } from './useTimeout';
export { default as useNewConvo } from './useNewConvo';
export { default as useLocalize } from './useLocalize';
+export { default as useFocusTrap } from './useFocusTrap';
export { default as useFavorites } from './useFavorites';
export { default as useChatBadges } from './useChatBadges';
export { default as useScrollToRef } from './useScrollToRef';
diff --git a/client/src/hooks/useFocusTrap.ts b/client/src/hooks/useFocusTrap.ts
new file mode 100644
index 0000000000..42c78ddf0f
--- /dev/null
+++ b/client/src/hooks/useFocusTrap.ts
@@ -0,0 +1,68 @@
+import { useEffect, useRef, type RefObject } from 'react';
+
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export default function useFocusTrap(
+ containerRef: RefObject,
+ active: boolean,
+ onEscape?: () => void,
+) {
+ const onEscapeRef = useRef(onEscape);
+ useEffect(() => {
+ onEscapeRef.current = onEscape;
+ }, [onEscape]);
+
+ useEffect(() => {
+ if (!active) {
+ return;
+ }
+
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ const getFocusableElements = () =>
+ Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
+ (el) => !el.closest('[aria-hidden="true"]'),
+ );
+
+ // Focus first focusable element on open
+ const focusableElements = getFocusableElements();
+ if (focusableElements.length > 0) {
+ focusableElements[0].focus();
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ onEscapeRef.current?.();
+ return;
+ }
+
+ if (e.key !== 'Tab') {
+ return;
+ }
+
+ const elements = getFocusableElements();
+ if (elements.length === 0) {
+ return;
+ }
+
+ const first = elements[0];
+ const last = elements[elements.length - 1];
+
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+
+ container.addEventListener('keydown', handleKeyDown);
+ return () => container.removeEventListener('keydown', handleKeyDown);
+ }, [active, containerRef]);
+}
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 7fe143dc22..89cc21abc7 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -293,7 +293,6 @@
"com_endpoint_message_not_appendable": "Edit your message or Regenerate.",
"com_endpoint_my_preset": "My Preset",
"com_endpoint_no_presets": "No presets yet, use the settings button to create one",
- "com_endpoint_open_menu": "Open Menu",
"com_endpoint_openai_custom_name_placeholder": "Set a custom name for the AI",
"com_endpoint_openai_detail": "The resolution for Vision requests. \"Low\" is cheaper and faster, \"High\" is more detailed and expensive, and \"Auto\" will automatically choose between the two based on the image resolution.",
"com_endpoint_openai_freq": "Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.",
@@ -404,7 +403,7 @@
"com_info_heic_converting": "Converting HEIC image to JPEG...",
"com_nav_2fa": "Two-Factor Authentication (2FA)",
"com_nav_account_settings": "Account Settings",
- "com_nav_always_make_prod": "Always make new versions production",
+ "com_nav_always_make_prod": "Always make new prompt versions production",
"com_nav_archive_created_at": "Date Archived",
"com_nav_archive_name": "Name",
"com_nav_archived_chats": "Archived chats",
@@ -413,7 +412,8 @@
"com_nav_audio_play_error": "Error playing audio: {{0}}",
"com_nav_audio_process_error": "Error processing audio: {{0}}",
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
- "com_nav_auto_send_prompts": "Auto-send Prompts",
+ "com_nav_auto_send_prompts": "Send prompts on select",
+ "com_nav_auto_send_prompts_desc": "Automatically submit prompt to chat when selected",
"com_nav_auto_send_text": "Auto send text",
"com_nav_auto_transcribe_audio": "Auto transcribe audio",
"com_nav_automatic_playback": "Autoplay Latest Message",
@@ -654,6 +654,7 @@
"com_ui_add_first_bookmark": "Click on a chat to add one",
"com_ui_add_first_mcp_server": "Create your first MCP server to get started",
"com_ui_add_first_prompt": "Create your first prompt to get started",
+ "com_ui_add_labels": "Add Labels",
"com_ui_add_mcp": "Add MCP",
"com_ui_add_mcp_server": "Add MCP Server",
"com_ui_add_model_preset": "Add a model or preset for an additional response",
@@ -767,6 +768,7 @@
"com_ui_authentication_type": "Authentication Type",
"com_ui_auto": "Auto",
"com_ui_avatar": "Avatar",
+ "com_ui_available_options": "Available options",
"com_ui_azure": "Azure",
"com_ui_azure_ad": "Entra ID",
"com_ui_back": "Back",
@@ -821,6 +823,7 @@
"com_ui_clear_presets": "Clear Presets",
"com_ui_clear_search": "Clear search",
"com_ui_click_to_close": "Click to close",
+ "com_ui_click_to_edit": "Click to edit",
"com_ui_click_to_view_var": "Click to view {{0}}",
"com_ui_client_id": "Client ID",
"com_ui_client_secret": "Client Secret",
@@ -869,6 +872,7 @@
"com_ui_copy_thoughts_to_clipboard": "Copy thoughts to clipboard",
"com_ui_copy_to_clipboard": "Copy to clipboard",
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
+ "com_ui_copy_failed": "Failed to copy to clipboard",
"com_ui_create": "Create",
"com_ui_create_api_key": "Create API Key",
"com_ui_create_assistant": "Create Assistant",
@@ -935,6 +939,7 @@
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
"com_ui_deleted": "Deleted",
"com_ui_deleting": "Deleting...",
+ "com_ui_deploy": "Deploy",
"com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc",
"com_ui_description": "Description",
@@ -951,7 +956,6 @@
"com_ui_download_error_logs": "Download error logs",
"com_ui_drag_drop": "Drop any file here to add it to the conversation",
"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_duplicate_agent": "Duplicate Agent",
"com_ui_duplication_error": "There was an error duplicating the conversation",
@@ -1048,7 +1052,7 @@
"com_ui_generating": "Generating...",
"com_ui_generation_settings": "Generation Settings",
"com_ui_getting_started": "Getting Started",
- "com_ui_global_group": "something needs to go here. was empty",
+ "com_ui_global_group": "Global prompt",
"com_ui_go_back": "Go back",
"com_ui_go_to_conversation": "Go to conversation",
"com_ui_good_afternoon": "Good afternoon",
@@ -1086,8 +1090,9 @@
"com_ui_key_required": "API key is required",
"com_ui_last_used": "Last used",
"com_ui_late_night": "Happy late night",
+ "com_ui_labels": "Labels",
+ "com_ui_latest": "latest",
"com_ui_latest_footer": "Every AI for Everyone.",
- "com_ui_latest_production_version": "Latest production version",
"com_ui_latest_version": "Latest version",
"com_ui_leave_blank_to_keep": "Leave blank to keep existing",
"com_ui_librechat_code_api_key": "Get your LibreChat Code Interpreter API key",
@@ -1096,10 +1101,12 @@
"com_ui_light_theme_enabled": "Light theme enabled",
"com_ui_link_copied": "Link copied",
"com_ui_link_refreshed": "Link refreshed",
+ "com_ui_live": "live",
"com_ui_loading": "Loading...",
"com_ui_locked": "Locked",
"com_ui_logo": "{{0}} Logo",
"com_ui_low": "Low",
+ "com_ui_make_production": "Make Production",
"com_ui_manage": "Manage",
"com_ui_marketplace": "Marketplace",
"com_ui_marketplace_allow_use": "Allow using Marketplace",
@@ -1207,6 +1214,7 @@
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes were made",
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
+ "com_ui_no_labels": "No Labels",
"com_ui_no_mcp_servers": "No MCP servers yet",
"com_ui_no_mcp_servers_match": "No MCP servers match your filter",
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
@@ -1240,7 +1248,9 @@
"com_ui_open_var": "Open {{0}}",
"com_ui_openai": "OpenAI",
"com_ui_optional": "(optional)",
+ "com_ui_options": "options",
"com_ui_page": "Page",
+ "com_ui_pagination": "Pagination",
"com_ui_people": "people",
"com_ui_people_picker": "People Picker",
"com_ui_people_picker_allow_view_groups": "Allow viewing groups",
@@ -1255,8 +1265,12 @@
"com_ui_preview": "Preview",
"com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL",
+ "com_ui_production": "Production",
"com_ui_prompt": "Prompt",
- "com_ui_prompt_deleted": "{{0}} deleted",
+ "com_ui_prompt_category_selector_aria": "Prompt's category selector",
+ "com_ui_prompt_delete_confirm": "Are you sure you want to delete the '{{0}}' prompt?",
+ "com_ui_prompt_deleted_group": "Prompt group \"{{0}}\" deleted",
+ "com_ui_prompt_details": "{{name}} prompt details",
"com_ui_prompt_group_button": "{{name}} prompt, {{category}} category",
"com_ui_prompt_group_button_no_category": "{{name}} prompt",
"com_ui_prompt_groups": "Prompt Groups List",
@@ -1265,9 +1279,11 @@
"com_ui_prompt_name": "Prompt Name",
"com_ui_prompt_name_required": "Prompt Name is required",
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.",
+ "com_ui_prompt_renamed": "Prompt renamed successfully",
"com_ui_prompt_text": "Text",
"com_ui_prompt_text_required": "Text is required",
"com_ui_prompt_update_error": "There was an error updating the prompt",
+ "com_ui_prompt_variables_list": "Prompt variables list",
"com_ui_prompts": "Prompts",
"com_ui_prompts_allow_create": "Allow creating Prompts",
"com_ui_prompts_allow_share": "Allow sharing Prompts",
@@ -1359,6 +1375,8 @@
"com_ui_search_agent_category": "Search categories...",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
+ "com_ui_search_result_count": "{{count}} result found",
+ "com_ui_search_results_count": "{{count}} results found",
"com_ui_seconds": "seconds",
"com_ui_secret_key": "Secret Key",
"com_ui_select": "Select",
@@ -1404,13 +1422,16 @@
"com_ui_special_var_current_datetime": "Current Date & Time",
"com_ui_special_var_current_user": "Current User",
"com_ui_special_var_iso_datetime": "UTC ISO Datetime",
+ "com_ui_special_var_desc_current_date": "Today's date and day of the week",
+ "com_ui_special_var_desc_current_datetime": "Local date and time in your timezone",
+ "com_ui_special_var_desc_current_user": "Your account display name",
+ "com_ui_special_var_desc_iso_datetime": "UTC datetime in ISO 8601 format",
+ "com_ui_special": "special",
"com_ui_special_variable_added": "{{0}} special variable added.",
- "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_special_variables": "Special variables",
"com_ui_speech_not_supported": "Your browser does not support speech recognition",
"com_ui_speech_not_supported_use_external": "Your browser does not support speech recognition. Try switching to External STT in Settings > Speech.",
"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_sr_global_prompt": "Global prompt group",
"com_ui_stack_trace": "Stack Trace",
"com_ui_status_prefix": "Status:",
@@ -1428,6 +1449,7 @@
"com_ui_support_contact_name_placeholder": "Support contact name",
"com_ui_teach_or_explain": "Learning",
"com_ui_temporary": "Temporary Chat",
+ "com_ui_text_variables": "Text variables",
"com_ui_terms_and_conditions": "Terms and Conditions",
"com_ui_terms_of_service": "Terms of service",
"com_ui_thinking": "Thinking...",
@@ -1484,13 +1506,14 @@
"com_ui_use_backup_code": "Use Backup Code Instead",
"com_ui_use_memory": "Use memory",
"com_ui_use_micrphone": "Use microphone",
+ "com_ui_use_prompt": "Use Prompt",
"com_ui_used": "Used",
"com_ui_user": "User",
"com_ui_user_group_permissions": "User & Group Permissions",
"com_ui_user_provides_key": "Each user provides their own key",
"com_ui_value": "Value",
"com_ui_variables": "Variables",
- "com_ui_variables_info": "Use double braces in your text to create variables, e.g. `{{example variable}}`, to later fill when using the prompt.",
+ "com_ui_variable_with_options": "{{name}} variable with {{count}} options",
"com_ui_verify": "Verify",
"com_ui_version_var": "Version {{0}}",
"com_ui_versions": "Versions",
diff --git a/client/src/routes/Layouts/DashBreadcrumb.tsx b/client/src/routes/Layouts/DashBreadcrumb.tsx
index 527fe058ad..94bb5c074f 100644
--- a/client/src/routes/Layouts/DashBreadcrumb.tsx
+++ b/client/src/routes/Layouts/DashBreadcrumb.tsx
@@ -1,6 +1,5 @@
import { useMemo, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
-import { Sidebar } from '@librechat/client';
import { useLocation } from 'react-router-dom';
import { SystemRoles } from 'librechat-data-provider';
import { ArrowLeft, MessageSquareQuote } from 'lucide-react';
@@ -12,8 +11,7 @@ import {
BreadcrumbSeparator,
} from '@librechat/client';
import { useLocalize, useCustomLink, useAuthContext } from '~/hooks';
-import AdvancedSwitch from '~/components/Prompts/AdvancedSwitch';
-import AdminSettings from '~/components/Prompts/AdminSettings';
+import { AdvancedSwitch, AdminSettings } from '~/components/Prompts';
import { useDashboardContext } from '~/Providers';
import store from '~/store';
@@ -27,15 +25,7 @@ const getConversationId = (prevLocationPath: string) => {
return lastPathnameParts[lastPathnameParts.length - 1];
};
-export default function DashBreadcrumb({
- showToggle = false,
- onToggle,
- openPanelRef,
-}: {
- showToggle?: boolean;
- onToggle?: () => void;
- openPanelRef?: React.RefObject;
-}) {
+export default function DashBreadcrumb() {
const location = useLocation();
const localize = useLocalize();
const { user } = useAuthContext();
@@ -62,24 +52,6 @@ export default function DashBreadcrumb({
- {showToggle && onToggle && (
- <>
-
-
-
-
-
-
- >
- )}
{
+ return {
+ pages,
+ pageParams: pages.map((_, i) => i),
+ };
+}
+
+function makeItem(id: string, name: string, value?: number): Item {
+ return { id, name, ...(value !== undefined ? { value } : {}) };
+}
+
+// ---------------------------------------------------------------------------
+// findPage
+// ---------------------------------------------------------------------------
+
+describe('findPage', () => {
+ it('returns correct pageIndex and index when item is on page 0', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha'), makeItem('b', 'Beta')] }]);
+ const result = findPage(data, (page) => page.items.findIndex((i) => i.id === 'b'));
+ expect(result).toEqual({ pageIndex: 0, index: 1 });
+ });
+
+ it('returns correct pageIndex and index when item is on a later page', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha')] },
+ { items: [makeItem('b', 'Beta'), makeItem('c', 'Gamma')] },
+ ]);
+ const result = findPage(data, (page) => page.items.findIndex((i) => i.id === 'c'));
+ expect(result).toEqual({ pageIndex: 1, index: 1 });
+ });
+
+ it('returns { pageIndex: -1, index: -1 } when item is not found', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+ const result = findPage(data, (page) => page.items.findIndex((i) => i.id === 'z'));
+ expect(result).toEqual({ pageIndex: -1, index: -1 });
+ });
+
+ it('returns { pageIndex: -1, index: -1 } when pages array is empty', () => {
+ const data = makeInfiniteData([]);
+ const result = findPage(data, (page) => page['items']?.findIndex(() => true) ?? -1);
+ expect(result).toEqual({ pageIndex: -1, index: -1 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// updateFieldsInPlace
+// ---------------------------------------------------------------------------
+
+describe('updateFieldsInPlace', () => {
+ it('updates matching item fields without changing its position', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha'), makeItem('b', 'Beta'), makeItem('c', 'Gamma')] },
+ ]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'b', name: 'BetaUpdated' },
+ 'items',
+ 'id',
+ );
+
+ // Item at index 1 is updated
+ expect(result.pages[0].items[1]).toMatchObject({ id: 'b', name: 'BetaUpdated' });
+ // Surrounding items are untouched
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'a', name: 'Alpha' });
+ expect(result.pages[0].items[2]).toMatchObject({ id: 'c', name: 'Gamma' });
+ // Total length preserved
+ expect(result.pages[0].items).toHaveLength(3);
+ });
+
+ it('does NOT move the updated item to page 0 (unlike updateFields)', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha')] },
+ { items: [makeItem('b', 'Beta'), makeItem('c', 'Gamma')] },
+ ]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'c', name: 'GammaUpdated' },
+ 'items',
+ 'id',
+ );
+
+ // Item stays on page 1, index 1
+ expect(result.pages[1].items[1]).toMatchObject({ id: 'c', name: 'GammaUpdated' });
+ // Page 0 is unchanged
+ expect(result.pages[0].items).toHaveLength(1);
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'a', name: 'Alpha' });
+ // Page 1 length preserved
+ expect(result.pages[1].items).toHaveLength(2);
+ });
+
+ it('does NOT set updatedAt on the updated item', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'a', name: 'AlphaChanged' },
+ 'items',
+ 'id',
+ );
+
+ expect(result.pages[0].items[0].updatedAt).toBeUndefined();
+ });
+
+ it('merges only the provided partial fields onto the existing item', () => {
+ const data = makeInfiniteData([{ items: [{ id: 'a', name: 'Alpha', value: 42 }] }]);
+
+ const result = updateFieldsInPlace(data, { id: 'a', value: 99 }, 'items', 'id');
+
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'a', name: 'Alpha', value: 99 });
+ });
+
+ it('returns the data unchanged when the item is not found', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'z', name: 'Missing' },
+ 'items',
+ 'id',
+ );
+
+ expect(result.pages[0].items).toHaveLength(1);
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'a', name: 'Alpha' });
+ });
+
+ it('handles items across multiple pages, updating the correct page', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha')] },
+ { items: [makeItem('b', 'Beta')] },
+ { items: [makeItem('c', 'Gamma')] },
+ ]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'b', name: 'BetaNew' },
+ 'items',
+ 'id',
+ );
+
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'a', name: 'Alpha' });
+ expect(result.pages[1].items[0]).toMatchObject({ id: 'b', name: 'BetaNew' });
+ expect(result.pages[2].items[0]).toMatchObject({ id: 'c', name: 'Gamma' });
+ });
+
+ it('does not mutate the original data', () => {
+ const original = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+ const snapshot = JSON.stringify(original);
+
+ updateFieldsInPlace(original, { id: 'a', name: 'Changed' }, 'items', 'id');
+
+ expect(JSON.stringify(original)).toBe(snapshot);
+ });
+
+ it('handles an empty pages array without throwing', () => {
+ const data = makeInfiniteData([]);
+
+ expect(() =>
+ updateFieldsInPlace(data, { id: 'a', name: 'Alpha' }, 'items', 'id'),
+ ).not.toThrow();
+ });
+
+ it('handles a page whose collection is empty without throwing', () => {
+ const data = makeInfiniteData([{ items: [] }]);
+
+ const result = updateFieldsInPlace(data, { id: 'a', name: 'Alpha' }, 'items', 'id');
+
+ expect(result.pages[0].items).toHaveLength(0);
+ });
+
+ it('updates the first item in a page correctly', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('first', 'First'), makeItem('second', 'Second')] },
+ ]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'first', name: 'FirstUpdated' },
+ 'items',
+ 'id',
+ );
+
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'first', name: 'FirstUpdated' });
+ expect(result.pages[0].items[1]).toMatchObject({ id: 'second', name: 'Second' });
+ });
+
+ it('updates the last item in a page correctly', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('first', 'First'), makeItem('last', 'Last')] },
+ ]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'last', name: 'LastUpdated' },
+ 'items',
+ 'id',
+ );
+
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'first', name: 'First' });
+ expect(result.pages[0].items[1]).toMatchObject({ id: 'last', name: 'LastUpdated' });
+ });
+
+ it('preserves pageParams on the returned data', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha')] },
+ { items: [makeItem('b', 'Beta')] },
+ ]);
+
+ const result = updateFieldsInPlace(
+ data,
+ { id: 'a', name: 'AlphaNew' },
+ 'items',
+ 'id',
+ );
+
+ expect(result.pageParams).toEqual(data.pageParams);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Contrast: updateFields DOES move item and set updatedAt
+// ---------------------------------------------------------------------------
+
+describe('updateFields (contrast with updateFieldsInPlace)', () => {
+ it('moves the updated item to page 0 and sets updatedAt', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha')] },
+ { items: [makeItem('b', 'Beta')] },
+ ]);
+
+ const result = updateFields(data, { id: 'b', name: 'BetaNew' }, 'items', 'id');
+
+ // Item is now at the top of page 0
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'b', name: 'BetaNew' });
+ expect(result.pages[0].items[0].updatedAt).toBeDefined();
+ // Page 1 is now empty (item was removed)
+ expect(result.pages[1].items).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getRecordByProperty
+// ---------------------------------------------------------------------------
+
+describe('getRecordByProperty', () => {
+ it('returns the matching record from page 0', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha'), makeItem('b', 'Beta')] }]);
+ const result = getRecordByProperty(data, 'items', (item) => item.id === 'b');
+ expect(result).toMatchObject({ id: 'b', name: 'Beta' });
+ });
+
+ it('returns the matching record from a later page', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha')] },
+ { items: [makeItem('b', 'Beta')] },
+ ]);
+ const result = getRecordByProperty(data, 'items', (item) => item.id === 'b');
+ expect(result).toMatchObject({ id: 'b', name: 'Beta' });
+ });
+
+ it('returns undefined when no item matches', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+ const result = getRecordByProperty(data, 'items', (item) => item.id === 'z');
+ expect(result).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// addData
+// ---------------------------------------------------------------------------
+
+describe('addData', () => {
+ it('unshifts new item onto page 0 when item does not already exist', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+ const newItem = makeItem('b', 'Beta');
+
+ const result = addData(data, 'items', newItem, (page) =>
+ page.items.findIndex((i) => i.id === newItem.id),
+ );
+
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'b', name: 'Beta' });
+ expect(result.pages[0].items).toHaveLength(2);
+ });
+
+ it('calls updateData instead when item already exists', () => {
+ const existing = makeItem('a', 'Alpha');
+ const data = makeInfiniteData([{ items: [existing] }]);
+
+ const result = addData(data, 'items', { id: 'a', name: 'AlphaUpdated' }, (page) =>
+ page.items.findIndex((i) => i.id === 'a'),
+ );
+
+ // updateData moves the item to the top with an updatedAt
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'a', name: 'AlphaUpdated' });
+ expect(result.pages[0].items[0].updatedAt).toBeDefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// deleteData
+// ---------------------------------------------------------------------------
+
+describe('deleteData', () => {
+ it('removes the matching item from its page', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha'), makeItem('b', 'Beta')] }]);
+
+ const result = deleteData(data, 'items', (page) =>
+ page.items.findIndex((i) => i.id === 'a'),
+ );
+
+ expect(result.pages[0].items).toHaveLength(1);
+ expect(result.pages[0].items[0]).toMatchObject({ id: 'b', name: 'Beta' });
+ });
+
+ it('leaves data unchanged when item is not found', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'Alpha')] }]);
+
+ const result = deleteData(data, 'items', (page) =>
+ page.items.findIndex((i) => i.id === 'z'),
+ );
+
+ expect(result.pages[0].items).toHaveLength(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// normalizeData
+// ---------------------------------------------------------------------------
+
+describe('normalizeData', () => {
+ it('redistributes items evenly according to pageSize', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')] },
+ { items: [makeItem('d', 'D')] },
+ ]);
+
+ const result = normalizeData(data, 'items', 2);
+
+ expect(result.pages[0].items).toHaveLength(2);
+ expect(result.pages[1].items).toHaveLength(2);
+ });
+
+ it('removes empty pages after redistribution', () => {
+ const data = makeInfiniteData([{ items: [makeItem('a', 'A')] }, { items: [] }]);
+
+ const result = normalizeData(data, 'items', 10);
+
+ expect(result.pages).toHaveLength(1);
+ });
+
+ it('deduplicates items when uniqueProperty is provided', () => {
+ const data = makeInfiniteData([
+ { items: [makeItem('a', 'Alpha'), makeItem('a', 'AlphaDuplicate')] },
+ ]);
+
+ const result = normalizeData(data, 'items', 10, 'id');
+
+ const ids = result.pages.flatMap((p) => p.items.map((i) => i.id));
+ expect(ids.filter((id) => id === 'a')).toHaveLength(1);
+ });
+
+ it('returns the data unchanged when pages array is empty', () => {
+ const data = makeInfiniteData([]);
+ const result = normalizeData(data, 'items', 10);
+ expect(result.pages).toHaveLength(0);
+ });
+});
diff --git a/client/src/utils/__tests__/promptGroups.test.ts b/client/src/utils/__tests__/promptGroups.test.ts
new file mode 100644
index 0000000000..4fa2ccdebf
--- /dev/null
+++ b/client/src/utils/__tests__/promptGroups.test.ts
@@ -0,0 +1,328 @@
+import { InfiniteCollections } from 'librechat-data-provider';
+import type { InfiniteData } from '@tanstack/react-query';
+import type { PromptGroupListResponse, TPromptGroup } from 'librechat-data-provider';
+import {
+ addPromptGroup,
+ deletePromptGroup,
+ updateGroupFields,
+ updateGroupFieldsInPlace,
+ updatePromptGroup,
+ getSnippet,
+ findPromptGroup,
+} from '../promptGroups';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeGroup(overrides: Partial = {}): TPromptGroup {
+ return {
+ _id: 'group-1',
+ name: 'Default Group',
+ numberOfGenerations: 0,
+ onlyMyPrompts: false,
+ ...overrides,
+ } as TPromptGroup;
+}
+
+function makeInfiniteData(
+ pages: Array<{ promptGroups: TPromptGroup[] }>,
+): InfiniteData {
+ return {
+ pages: pages as PromptGroupListResponse[],
+ pageParams: pages.map((_, i) => i),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// updateGroupFieldsInPlace
+// ---------------------------------------------------------------------------
+
+describe('updateGroupFieldsInPlace', () => {
+ it('updates matching group fields without changing its position', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A' });
+ const groupB = makeGroup({ _id: 'b', name: 'Group B' });
+ const groupC = makeGroup({ _id: 'c', name: 'Group C' });
+
+ const data = makeInfiniteData([{ promptGroups: [groupA, groupB, groupC] }]);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'b', name: 'Group B Updated' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][1]).toMatchObject({
+ _id: 'b',
+ name: 'Group B Updated',
+ });
+ // Neighbours are unchanged
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({ _id: 'a' });
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][2]).toMatchObject({ _id: 'c' });
+ // Length is preserved
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(3);
+ });
+
+ it('does NOT move the group to page 0 (stays on original page)', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A' });
+ const groupB = makeGroup({ _id: 'b', name: 'Group B' });
+
+ const data = makeInfiniteData([{ promptGroups: [groupA] }, { promptGroups: [groupB] }]);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'b', name: 'Group B Updated' });
+
+ // Group B stays on page 1
+ expect(result.pages[1][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'b',
+ name: 'Group B Updated',
+ });
+ // Page 0 is unchanged
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(1);
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({ _id: 'a' });
+ });
+
+ it('does NOT set updatedAt on the updated group', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A' });
+ const data = makeInfiniteData([{ promptGroups: [groupA] }]);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'a', name: 'Changed' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0].updatedAt).toBeUndefined();
+ });
+
+ it('returns data unchanged when the group is not found', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A' });
+ const data = makeInfiniteData([{ promptGroups: [groupA] }]);
+ const snapshot = JSON.stringify(data);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'nonexistent', name: 'Ghost' });
+
+ expect(JSON.stringify(result)).toBe(snapshot);
+ });
+
+ it('merges only the provided partial fields, preserving others', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A', numberOfGenerations: 5 });
+ const data = makeInfiniteData([{ promptGroups: [groupA] }]);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'a', name: 'Group A Renamed' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'a',
+ name: 'Group A Renamed',
+ numberOfGenerations: 5,
+ });
+ });
+
+ it('does not mutate the original data', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A' });
+ const data = makeInfiniteData([{ promptGroups: [groupA] }]);
+ const snapshot = JSON.stringify(data);
+
+ updateGroupFieldsInPlace(data, { _id: 'a', name: 'Changed' });
+
+ expect(JSON.stringify(data)).toBe(snapshot);
+ });
+
+ it('handles an empty pages array without throwing', () => {
+ const data = makeInfiniteData([]);
+ expect(() => updateGroupFieldsInPlace(data, { _id: 'a', name: 'Ghost' })).not.toThrow();
+ });
+
+ it('handles a page with an empty promptGroups array without throwing', () => {
+ const data = makeInfiniteData([{ promptGroups: [] }]);
+ const result = updateGroupFieldsInPlace(data, { _id: 'a', name: 'Ghost' });
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(0);
+ });
+
+ it('preserves pageParams on the returned data', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'Group A' });
+ const data = makeInfiniteData([{ promptGroups: [groupA] }]);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'a', name: 'Changed' });
+
+ expect(result.pageParams).toEqual(data.pageParams);
+ });
+
+ it('handles groups across three pages, updating only the matching page', () => {
+ const data = makeInfiniteData([
+ { promptGroups: [makeGroup({ _id: 'a', name: 'A' })] },
+ { promptGroups: [makeGroup({ _id: 'b', name: 'B' })] },
+ { promptGroups: [makeGroup({ _id: 'c', name: 'C' })] },
+ ]);
+
+ const result = updateGroupFieldsInPlace(data, { _id: 'c', name: 'C Updated' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'a',
+ name: 'A',
+ });
+ expect(result.pages[1][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'b',
+ name: 'B',
+ });
+ expect(result.pages[2][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'c',
+ name: 'C Updated',
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Contrast: updateGroupFields DOES move item and set updatedAt
+// ---------------------------------------------------------------------------
+
+describe('updateGroupFields (contrast with updateGroupFieldsInPlace)', () => {
+ it('moves updated group to page 0 and sets updatedAt', () => {
+ const data = makeInfiniteData([
+ { promptGroups: [makeGroup({ _id: 'a', name: 'A' })] },
+ { promptGroups: [makeGroup({ _id: 'b', name: 'B' })] },
+ ]);
+
+ const result = updateGroupFields(data, { _id: 'b', name: 'B Updated' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'b',
+ name: 'B Updated',
+ });
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0].updatedAt).toBeDefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// addPromptGroup
+// ---------------------------------------------------------------------------
+
+describe('addPromptGroup', () => {
+ it('adds a new group to the top of page 0', () => {
+ const existing = makeGroup({ _id: 'a', name: 'A' });
+ const data = makeInfiniteData([{ promptGroups: [existing] }]);
+ const newGroup = makeGroup({ _id: 'new', name: 'New Group' });
+
+ const result = addPromptGroup(data, newGroup);
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({ _id: 'new' });
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(2);
+ });
+
+ it('updates via updateData when the group already exists', () => {
+ const existing = makeGroup({ _id: 'a', name: 'A' });
+ const data = makeInfiniteData([{ promptGroups: [existing] }]);
+
+ const result = addPromptGroup(data, { ...existing, name: 'A Updated' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'a',
+ name: 'A Updated',
+ });
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0].updatedAt).toBeDefined();
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// updatePromptGroup
+// ---------------------------------------------------------------------------
+
+describe('updatePromptGroup', () => {
+ it('moves the updated group to the top of page 0 and sets updatedAt', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'A' });
+ const groupB = makeGroup({ _id: 'b', name: 'B' });
+ const data = makeInfiniteData([{ promptGroups: [groupA, groupB] }]);
+
+ const result = updatePromptGroup(data, { ...groupB, name: 'B Updated' });
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({
+ _id: 'b',
+ name: 'B Updated',
+ });
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0].updatedAt).toBeDefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// deletePromptGroup
+// ---------------------------------------------------------------------------
+
+describe('deletePromptGroup', () => {
+ it('removes the matching group from its page', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'A' });
+ const groupB = makeGroup({ _id: 'b', name: 'B' });
+ const data = makeInfiniteData([{ promptGroups: [groupA, groupB] }]);
+
+ const result = deletePromptGroup(data, 'a');
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(1);
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS][0]).toMatchObject({ _id: 'b' });
+ });
+
+ it('leaves data unchanged when the group is not found', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'A' });
+ const data = makeInfiniteData([{ promptGroups: [groupA] }]);
+
+ const result = deletePromptGroup(data, 'nonexistent');
+
+ expect(result.pages[0][InfiniteCollections.PROMPT_GROUPS]).toHaveLength(1);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// findPromptGroup
+// ---------------------------------------------------------------------------
+
+describe('findPromptGroup', () => {
+ it('returns matching group from page 0', () => {
+ const groupA = makeGroup({ _id: 'a', name: 'A' });
+ const groupB = makeGroup({ _id: 'b', name: 'B' });
+ const data = makeInfiniteData([{ promptGroups: [groupA, groupB] }]);
+
+ const result = findPromptGroup(data, (g) => g._id === 'b');
+
+ expect(result).toMatchObject({ _id: 'b', name: 'B' });
+ });
+
+ it('returns matching group from a later page', () => {
+ const data = makeInfiniteData([
+ { promptGroups: [makeGroup({ _id: 'a', name: 'A' })] },
+ { promptGroups: [makeGroup({ _id: 'b', name: 'B' })] },
+ ]);
+
+ const result = findPromptGroup(data, (g) => g._id === 'b');
+
+ expect(result).toMatchObject({ _id: 'b' });
+ });
+
+ it('returns undefined when no group matches', () => {
+ const data = makeInfiniteData([{ promptGroups: [makeGroup({ _id: 'a' })] }]);
+ expect(findPromptGroup(data, (g) => g._id === 'z')).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// getSnippet
+// ---------------------------------------------------------------------------
+
+describe('getSnippet', () => {
+ it('returns the full string when it is within the default length', () => {
+ const short = 'Hello';
+ expect(getSnippet(short)).toBe('Hello');
+ });
+
+ it('truncates and appends ellipsis when string exceeds default length', () => {
+ const long = 'a'.repeat(60);
+ const result = getSnippet(long);
+ expect(result.endsWith('...')).toBe(true);
+ expect(result.length).toBe(56);
+ });
+
+ it('respects a custom length parameter', () => {
+ const text = 'abcdefghij';
+ const result = getSnippet(text, 7);
+ expect(result).toBe('abcd...');
+ expect(result.length).toBe(7);
+ });
+
+ it('returns the original string when it equals the length exactly', () => {
+ const text = 'a'.repeat(56);
+ expect(getSnippet(text)).toBe(text);
+ });
+
+ it('handles an empty string without throwing', () => {
+ expect(getSnippet('')).toBe('');
+ });
+});
diff --git a/client/src/utils/collection.ts b/client/src/utils/collection.ts
index a46d45b4d6..81228801e2 100644
--- a/client/src/utils/collection.ts
+++ b/client/src/utils/collection.ts
@@ -166,6 +166,38 @@ export const updateFields = (
return newData;
};
+export const updateFieldsInPlace = (
+ data: InfiniteData,
+ updatedItem: Partial,
+ collectionName: string,
+ identifierField: keyof TData,
+): InfiniteData => {
+ const identifierValue = updatedItem[identifierField];
+ if (identifierValue == null) {
+ return data;
+ }
+
+ const { pageIndex, index } = findPage(data, (page) =>
+ page[collectionName].findIndex((item: TData) => item[identifierField] === identifierValue),
+ );
+
+ if (pageIndex === -1 || index === -1) {
+ return data;
+ }
+
+ const oldItem = data.pages[pageIndex][collectionName][index];
+ const newItem = { ...oldItem, ...updatedItem };
+
+ const newCollection = [...data.pages[pageIndex][collectionName]];
+ newCollection[index] = newItem;
+
+ const newPage = { ...data.pages[pageIndex], [collectionName]: newCollection };
+ const newPages = [...data.pages];
+ newPages[pageIndex] = newPage;
+
+ return { ...data, pages: newPages };
+};
+
type UpdateCacheListOptions = {
queryClient: QueryClient;
queryKey: unknown[];
diff --git a/client/src/utils/promptGroups.ts b/client/src/utils/promptGroups.ts
index b33ca0ac4c..d5cadfb8d4 100644
--- a/client/src/utils/promptGroups.ts
+++ b/client/src/utils/promptGroups.ts
@@ -12,6 +12,7 @@ import {
updateFields,
addToCacheList,
updateCacheList,
+ updateFieldsInPlace,
removeFromCacheList,
getRecordByProperty,
} from './collection';
@@ -65,6 +66,18 @@ export const updateGroupFields = (
);
};
+export const updateGroupFieldsInPlace = (
+ data: InfiniteData,
+ updatedGroup: Partial,
+): InfiniteData => {
+ return updateFieldsInPlace(
+ data,
+ updatedGroup,
+ InfiniteCollections.PROMPT_GROUPS,
+ '_id',
+ );
+};
+
export const getSnippet = (promptText: string, length = 56) => {
return promptText.length > length ? `${promptText.slice(0, length - 3)}...` : promptText;
};
diff --git a/packages/api/src/prompts/format.spec.ts b/packages/api/src/prompts/format.spec.ts
new file mode 100644
index 0000000000..1d67f43837
--- /dev/null
+++ b/packages/api/src/prompts/format.spec.ts
@@ -0,0 +1,125 @@
+import { Types } from 'mongoose';
+import { filterAccessibleIdsBySharedLogic } from './format';
+
+const id = () => new Types.ObjectId();
+
+describe('filterAccessibleIdsBySharedLogic', () => {
+ const ownedA = id();
+ const ownedB = id();
+ const sharedC = id();
+ const sharedD = id();
+ const publicE = id();
+ const publicF = id();
+
+ // accessible = everything the ACL resolver says this user can see
+ const accessibleIds = [ownedA, ownedB, sharedC, sharedD];
+ const ownedPromptGroupIds = [ownedA, ownedB];
+ const publicPromptGroupIds = [publicE, publicF];
+
+ function toStrings(ids: Types.ObjectId[]) {
+ return ids.map((i) => i.toString()).sort();
+ }
+
+ describe('MY_PROMPTS (searchShared=false)', () => {
+ it('returns only owned IDs when ownedPromptGroupIds provided', async () => {
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared: false,
+ searchSharedOnly: false,
+ publicPromptGroupIds,
+ ownedPromptGroupIds,
+ });
+ expect(toStrings(result)).toEqual(toStrings([ownedA, ownedB]));
+ });
+
+ it('legacy fallback: excludes public IDs when ownedPromptGroupIds omitted', async () => {
+ // accessible includes a public ID for this test
+ const accessible = [ownedA, ownedB, publicE];
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds: accessible,
+ searchShared: false,
+ searchSharedOnly: false,
+ publicPromptGroupIds,
+ });
+ // Should exclude publicE, keep ownedA and ownedB
+ expect(toStrings(result)).toEqual(toStrings([ownedA, ownedB]));
+ });
+ });
+
+ describe('SHARED_PROMPTS (searchSharedOnly=true)', () => {
+ it('returns accessible + public minus owned when ownedPromptGroupIds provided', async () => {
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared: true,
+ searchSharedOnly: true,
+ publicPromptGroupIds,
+ ownedPromptGroupIds,
+ });
+ // Should include sharedC, sharedD, publicE, publicF (not ownedA, ownedB)
+ expect(toStrings(result)).toEqual(toStrings([sharedC, sharedD, publicE, publicF]));
+ });
+
+ it('deduplicates when an ID appears in both accessible and public', async () => {
+ const overlap = id();
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds: [ownedA, overlap],
+ searchShared: true,
+ searchSharedOnly: true,
+ publicPromptGroupIds: [overlap, publicE],
+ ownedPromptGroupIds: [ownedA],
+ });
+ // overlap should appear once, not twice
+ expect(toStrings(result)).toEqual(toStrings([overlap, publicE]));
+ });
+
+ it('legacy fallback: returns intersection of public and accessible when ownedPromptGroupIds omitted', async () => {
+ // publicE is also accessible
+ const accessible = [ownedA, publicE];
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds: accessible,
+ searchShared: true,
+ searchSharedOnly: true,
+ publicPromptGroupIds: [publicE, publicF],
+ });
+ // Only publicE is in both accessible and public
+ expect(toStrings(result)).toEqual(toStrings([publicE]));
+ });
+
+ it('legacy fallback: returns empty when no public IDs', async () => {
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared: true,
+ searchSharedOnly: true,
+ publicPromptGroupIds: [],
+ });
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('ALL (searchShared=true, searchSharedOnly=false)', () => {
+ it('returns union of accessible + public, deduplicated', async () => {
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds,
+ searchShared: true,
+ searchSharedOnly: false,
+ publicPromptGroupIds,
+ ownedPromptGroupIds,
+ });
+ expect(toStrings(result)).toEqual(
+ toStrings([ownedA, ownedB, sharedC, sharedD, publicE, publicF]),
+ );
+ });
+
+ it('deduplicates overlapping IDs', async () => {
+ const overlap = id();
+ const result = await filterAccessibleIdsBySharedLogic({
+ accessibleIds: [ownedA, overlap],
+ searchShared: true,
+ searchSharedOnly: false,
+ publicPromptGroupIds: [overlap, publicE],
+ ownedPromptGroupIds,
+ });
+ expect(toStrings(result)).toEqual(toStrings([ownedA, overlap, publicE]));
+ });
+ });
+});
diff --git a/packages/api/src/prompts/format.ts b/packages/api/src/prompts/format.ts
index de3d4e8a74..63c50247e9 100644
--- a/packages/api/src/prompts/format.ts
+++ b/packages/api/src/prompts/format.ts
@@ -81,22 +81,12 @@ export function markPublicPromptGroups(
/**
* Builds filter object for prompt group queries
*/
-export function buildPromptGroupFilter({
- name,
- category,
- ...otherFilters
-}: {
- name?: string;
- category?: string;
- [key: string]: string | number | boolean | RegExp | undefined;
-}): {
+export function buildPromptGroupFilter({ name, category }: { name?: string; category?: string }): {
filter: Record;
searchShared: boolean;
searchSharedOnly: boolean;
} {
- const filter: Record = {
- ...otherFilters,
- };
+ const filter: Record = {};
let searchShared = true;
let searchSharedOnly = false;
@@ -120,28 +110,47 @@ export function buildPromptGroupFilter({
}
/**
- * Filters accessible IDs based on shared/public prompts logic
+ * Filters accessible IDs based on shared/public prompts logic.
+ *
+ * @param ownedPromptGroupIds - IDs of prompt groups authored by the current user.
+ * Required for correct MY_PROMPTS and SHARED_PROMPTS filtering. When omitted the
+ * function falls back to the legacy behaviour (public-only filtering).
*/
export async function filterAccessibleIdsBySharedLogic({
accessibleIds,
searchShared,
searchSharedOnly,
publicPromptGroupIds,
+ ownedPromptGroupIds,
}: {
accessibleIds: Types.ObjectId[];
searchShared: boolean;
searchSharedOnly: boolean;
publicPromptGroupIds?: Types.ObjectId[];
+ ownedPromptGroupIds?: Types.ObjectId[];
}): Promise {
- const publicIdStrings = new Set((publicPromptGroupIds || []).map((id) => id.toString()));
+ const ownedIdStrings = new Set((ownedPromptGroupIds || []).map((id) => id.toString()));
if (!searchShared) {
- // For MY_PROMPTS - exclude public prompts to show only user's own prompts
+ // MY_PROMPTS — only prompt groups the user authored
+ if (ownedPromptGroupIds != null) {
+ return accessibleIds.filter((id) => ownedIdStrings.has(id.toString()));
+ }
+ // Legacy fallback: exclude public IDs (imprecise but backwards-compatible)
+ const publicIdStrings = new Set((publicPromptGroupIds || []).map((id) => id.toString()));
return accessibleIds.filter((id) => !publicIdStrings.has(id.toString()));
}
if (searchSharedOnly) {
- // Handle SHARED_PROMPTS filter - only return public prompts that user has access to
+ // SHARED_PROMPTS — all prompts the user can access that they did NOT author
+ // Combine accessible + public, deduplicate, then exclude owned
+ const allAccessible = [...accessibleIds, ...(publicPromptGroupIds || [])];
+ const uniqueMap = new Map(allAccessible.map((id) => [id.toString(), id]));
+
+ if (ownedPromptGroupIds != null) {
+ return [...uniqueMap.values()].filter((id) => !ownedIdStrings.has(id.toString()));
+ }
+ // Legacy fallback
if (!publicPromptGroupIds?.length) {
return [];
}
@@ -149,5 +158,8 @@ export async function filterAccessibleIdsBySharedLogic({
return publicPromptGroupIds.filter((id) => accessibleIdStrings.has(id.toString()));
}
- return [...accessibleIds, ...(publicPromptGroupIds || [])];
+ // ALL — return everything accessible + public (deduplicated)
+ const allAccessible = [...accessibleIds, ...(publicPromptGroupIds || [])];
+ const uniqueMap = new Map(allAccessible.map((id) => [id.toString(), id]));
+ return [...uniqueMap.values()];
}
diff --git a/packages/client/src/components/Dropdown.tsx b/packages/client/src/components/Dropdown.tsx
index 63aed0ac76..3d370cff75 100644
--- a/packages/client/src/components/Dropdown.tsx
+++ b/packages/client/src/components/Dropdown.tsx
@@ -74,7 +74,7 @@ const Dropdown: React.FC = ({
store={selectProps}
className={cn(
'focus:ring-offset-ring-offset relative inline-flex items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm text-text-primary transition-all duration-200 ease-in-out hover:bg-accent hover:text-accent-foreground focus:ring-ring-primary',
- iconOnly ? 'h-full w-10' : 'w-fit gap-2',
+ iconOnly ? 'size-10' : 'w-fit gap-2',
className,
)}
data-testid={testId}
diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json
index 3f6925c479..a66f4eec4e 100644
--- a/packages/data-provider/package.json
+++ b/packages/data-provider/package.json
@@ -5,6 +5,7 @@
"main": "dist/index.js",
"module": "dist/index.es.js",
"types": "./dist/types/index.d.ts",
+ "sideEffects": false,
"exports": {
".": {
"import": "./dist/index.es.js",
diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts
index 762a0ce859..00e860422c 100644
--- a/packages/data-provider/src/api-endpoints.ts
+++ b/packages/data-provider/src/api-endpoints.ts
@@ -343,6 +343,8 @@ export const postPrompt = prompts;
export const updatePromptGroup = getPromptGroup;
+export const recordPromptGroupUsage = (groupId: string) => `${prompts()}/groups/${groupId}/use`;
+
export const updatePromptLabels = (_id: string) => `${getPrompt(_id)}/labels`;
export const updatePromptTag = (_id: string) => `${getPrompt(_id)}/tags/production`;
diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts
index 2c7a402d1f..6d1be48090 100644
--- a/packages/data-provider/src/data-service.ts
+++ b/packages/data-provider/src/data-service.ts
@@ -833,6 +833,10 @@ export function updatePromptGroup(
return request.patch(endpoints.updatePromptGroup(variables.id), variables.payload);
}
+export function recordPromptGroupUsage(groupId: string): Promise<{ numberOfGenerations: number }> {
+ return request.post(endpoints.recordPromptGroupUsage(groupId));
+}
+
export function deletePrompt(payload: t.TDeletePromptVariables): Promise {
return request.delete(endpoints.deletePrompt(payload));
}
diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts
index 485599c6f7..aa92b3b2e6 100644
--- a/packages/data-schemas/src/index.ts
+++ b/packages/data-schemas/src/index.ts
@@ -20,4 +20,4 @@ export { default as logger } from './config/winston';
export { default as meiliLogger } from './config/meiliLogger';
export { tenantStorage, getTenantId, runAsSystem, SYSTEM_TENANT_ID } from './config/tenantContext';
export type { TenantContext } from './config/tenantContext';
-export { dropSupersededTenantIndexes } from './migrations';
+export { dropSupersededTenantIndexes, dropSupersededPromptGroupIndexes } from './migrations';
diff --git a/packages/data-schemas/src/methods/prompt.ts b/packages/data-schemas/src/methods/prompt.ts
index 8fa8fd1a53..a1b6bfde37 100644
--- a/packages/data-schemas/src/methods/prompt.ts
+++ b/packages/data-schemas/src/methods/prompt.ts
@@ -2,6 +2,7 @@ import { ResourceType, SystemCategories } from 'librechat-data-provider';
import type { Model, Types } from 'mongoose';
import type { IAclEntry, IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types';
import { escapeRegExp } from '~/utils/string';
+import { isValidObjectIdString } from '~/utils/objectId';
import logger from '~/config/winston';
export interface PromptDeps {
@@ -72,12 +73,14 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
}
const groups = await PromptGroup.find(query)
- .sort({ createdAt: -1 })
- .select('name oneliner category author authorName createdAt updatedAt command productionId')
+ .sort({ numberOfGenerations: -1, updatedAt: -1, _id: 1 })
+ .select(
+ 'name numberOfGenerations oneliner category author authorName createdAt updatedAt command productionId',
+ )
.lean();
return await attachProductionPrompts(groups as unknown as Array>);
} catch (error) {
- console.error('Error getting all prompt groups', error);
+ logger.error('Error getting all prompt groups', error);
return { message: 'Error getting all prompt groups' };
}
}
@@ -122,7 +125,7 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
const [groups, totalPromptGroups] = await Promise.all([
PromptGroup.find(query)
- .sort({ createdAt: -1 })
+ .sort({ numberOfGenerations: -1, updatedAt: -1, _id: 1 })
.skip(skip)
.limit(limit)
.select(
@@ -143,7 +146,7 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
pages: Math.ceil(totalPromptGroups / validatedPageSize).toString(),
};
} catch (error) {
- console.error('Error getting prompt groups', error);
+ logger.error('Error getting prompt groups', error);
return { message: 'Error getting prompt groups' };
}
}
@@ -207,35 +210,52 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
_id: { $in: accessibleIds },
};
+ let matchQuery: Record = baseQuery;
+
if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') {
try {
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
- const { updatedAt, _id } = cursor;
+ const { numberOfGenerations = 0, updatedAt, _id } = cursor;
- const cursorCondition = {
- $or: [
- { updatedAt: { $lt: new Date(updatedAt) } },
- { updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
- ],
- };
-
- if (Object.keys(baseQuery).length > 0) {
- baseQuery.$and = [{ ...baseQuery }, cursorCondition];
- Object.keys(baseQuery).forEach((key) => {
- if (key !== '$and') {
- delete baseQuery[key];
- }
- });
+ if (
+ typeof numberOfGenerations !== 'number' ||
+ !Number.isFinite(numberOfGenerations) ||
+ typeof updatedAt !== 'string' ||
+ Number.isNaN(new Date(updatedAt).getTime()) ||
+ typeof _id !== 'string' ||
+ !isValidObjectIdString(_id)
+ ) {
+ logger.warn(
+ '[getListPromptGroupsByAccess] Invalid cursor fields, skipping cursor condition',
+ );
} else {
- Object.assign(baseQuery, cursorCondition);
+ const cursorCondition = {
+ $or: [
+ { numberOfGenerations: { $lt: numberOfGenerations } },
+ {
+ numberOfGenerations,
+ updatedAt: { $lt: new Date(updatedAt) },
+ },
+ {
+ numberOfGenerations,
+ updatedAt: new Date(updatedAt),
+ _id: { $gt: new ObjectId(_id) },
+ },
+ ],
+ };
+
+ matchQuery =
+ Object.keys(baseQuery).length > 0
+ ? { $and: [baseQuery, cursorCondition] }
+ : cursorCondition;
}
} catch (error) {
logger.warn('Invalid cursor:', (error as Error).message);
}
}
- const findQuery = PromptGroup.find(baseQuery)
- .sort({ updatedAt: -1, _id: 1 })
+ const findQuery = PromptGroup.find(matchQuery)
+ .sort({ numberOfGenerations: -1, updatedAt: -1, _id: 1 })
.select(
'name numberOfGenerations oneliner category productionId author authorName createdAt updatedAt',
);
@@ -264,6 +284,7 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
const lastGroup = promptGroups[normalizedLimit - 1] as Record;
nextCursor = Buffer.from(
JSON.stringify({
+ numberOfGenerations: lastGroup.numberOfGenerations,
updatedAt: (lastGroup.updatedAt as Date).toISOString(),
_id: (lastGroup._id as Types.ObjectId).toString(),
}),
@@ -280,6 +301,28 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
};
}
+ /**
+ * Increment the numberOfGenerations counter for a prompt group.
+ */
+ async function incrementPromptGroupUsage(groupId: string) {
+ if (!isValidObjectIdString(groupId)) {
+ throw new Error('Invalid groupId');
+ }
+
+ const PromptGroup = mongoose.models.PromptGroup as Model;
+ const result = await PromptGroup.findByIdAndUpdate(
+ groupId,
+ { $inc: { numberOfGenerations: 1 } },
+ { new: true, select: 'numberOfGenerations' },
+ ).lean();
+
+ if (!result) {
+ throw new Error('Prompt group not found');
+ }
+
+ return { numberOfGenerations: result.numberOfGenerations };
+ }
+
/**
* Create a prompt and its respective group.
*/
@@ -455,15 +498,56 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
}
/**
- * Get a single prompt group by filter.
+ * Get a single prompt group by filter, with productionPrompt populated via $lookup.
*/
async function getPromptGroup(filter: Record) {
try {
const PromptGroup = mongoose.models.PromptGroup as Model;
- return await PromptGroup.findOne(filter).lean();
+ // Cast string _id to ObjectId for aggregation (findOne auto-casts, aggregate does not)
+ const matchFilter = { ...filter };
+ if (typeof matchFilter._id === 'string') {
+ matchFilter._id = new ObjectId(matchFilter._id);
+ }
+ const result = await PromptGroup.aggregate([
+ { $match: matchFilter },
+ {
+ $lookup: {
+ from: 'prompts',
+ localField: 'productionId',
+ foreignField: '_id',
+ as: 'productionPrompt',
+ },
+ },
+ { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
+ ]);
+ const group = result[0] || null;
+ if (group?.author) {
+ group.author = group.author.toString();
+ }
+ return group;
} catch (error) {
logger.error('Error getting prompt group', error);
- return { message: 'Error getting prompt group' };
+ return null;
+ }
+ }
+
+ /**
+ * Returns the _id values of all prompt groups authored by the given user.
+ * Used by the "Shared Prompts" and "My Prompts" filters to distinguish
+ * owned prompts from prompts shared with the user.
+ */
+ async function getOwnedPromptGroupIds(author: string) {
+ try {
+ const PromptGroup = mongoose.models.PromptGroup as Model;
+ if (!author || !ObjectId.isValid(author)) {
+ logger.warn('getOwnedPromptGroupIds called with invalid author', { author });
+ return [];
+ }
+ const groups = await PromptGroup.find({ author: new ObjectId(author) }, { _id: 1 }).lean();
+ return groups.map((g) => g._id);
+ } catch (error) {
+ logger.error('Error getting owned prompt group IDs', error);
+ return [];
}
}
@@ -657,6 +741,7 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
deletePromptGroup,
getAllPromptGroups,
getListPromptGroupsByAccess,
+ incrementPromptGroupUsage,
createPromptGroup,
savePrompt,
getPrompts,
@@ -664,6 +749,7 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P
getRandomPromptGroups,
getPromptGroupsWithPrompts,
getPromptGroup,
+ getOwnedPromptGroupIds,
deletePrompt,
deleteUserPrompts,
updatePromptGroup,
diff --git a/packages/data-schemas/src/migrations/index.ts b/packages/data-schemas/src/migrations/index.ts
index 165b34dbf8..eda2293cb0 100644
--- a/packages/data-schemas/src/migrations/index.ts
+++ b/packages/data-schemas/src/migrations/index.ts
@@ -1 +1,2 @@
export { dropSupersededTenantIndexes } from './tenantIndexes';
+export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes';
diff --git a/packages/data-schemas/src/migrations/promptGroupIndexes.ts b/packages/data-schemas/src/migrations/promptGroupIndexes.ts
new file mode 100644
index 0000000000..4b6013c9e4
--- /dev/null
+++ b/packages/data-schemas/src/migrations/promptGroupIndexes.ts
@@ -0,0 +1,65 @@
+import type { Connection } from 'mongoose';
+import logger from '~/config/winston';
+
+/**
+ * Old promptGroup indexes that were replaced:
+ * { createdAt: 1, updatedAt: 1 } → { numberOfGenerations: -1, updatedAt: -1, _id: 1 }
+ *
+ * Mongoose creates new indexes on startup but does NOT drop old ones.
+ * This migration removes the superseded index to avoid wasted storage and write overhead.
+ */
+const SUPERSEDED_PROMPT_GROUP_INDEXES = ['createdAt_1_updatedAt_1'];
+
+export async function dropSupersededPromptGroupIndexes(
+ connection: Connection,
+): Promise<{ dropped: string[]; skipped: string[]; errors: string[] }> {
+ const result = { dropped: [] as string[], skipped: [] as string[], errors: [] as string[] };
+ const collectionName = 'promptgroups';
+
+ let collection;
+ try {
+ collection = connection.db.collection(collectionName);
+ } catch {
+ result.skipped.push(
+ ...SUPERSEDED_PROMPT_GROUP_INDEXES.map(
+ (idx) => `${collectionName}.${idx} (collection does not exist)`,
+ ),
+ );
+ return result;
+ }
+
+ let existingIndexes: Array<{ name?: string }>;
+ try {
+ existingIndexes = await collection.indexes();
+ } catch {
+ result.skipped.push(
+ ...SUPERSEDED_PROMPT_GROUP_INDEXES.map(
+ (idx) => `${collectionName}.${idx} (could not list indexes)`,
+ ),
+ );
+ return result;
+ }
+
+ const existingNames = new Set(existingIndexes.map((idx) => idx.name));
+
+ for (const indexName of SUPERSEDED_PROMPT_GROUP_INDEXES) {
+ if (!existingNames.has(indexName)) {
+ result.skipped.push(`${collectionName}.${indexName}`);
+ continue;
+ }
+
+ try {
+ await collection.dropIndex(indexName);
+ result.dropped.push(`${collectionName}.${indexName}`);
+ logger.info(
+ `[PromptGroupMigration] Dropped superseded index: ${collectionName}.${indexName}`,
+ );
+ } catch (err) {
+ const msg = `${collectionName}.${indexName}: ${(err as Error).message}`;
+ result.errors.push(msg);
+ logger.error(`[PromptGroupMigration] Failed to drop index: ${msg}`);
+ }
+ }
+
+ return result;
+}
diff --git a/packages/data-schemas/src/schema/prompt.ts b/packages/data-schemas/src/schema/prompt.ts
index dd32789727..f42bfcc8e7 100644
--- a/packages/data-schemas/src/schema/prompt.ts
+++ b/packages/data-schemas/src/schema/prompt.ts
@@ -13,6 +13,7 @@ const promptSchema: Schema = new Schema(
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
+ index: true,
},
prompt: {
type: String,
diff --git a/packages/data-schemas/src/schema/promptGroup.ts b/packages/data-schemas/src/schema/promptGroup.ts
index bd4db546e3..629942f23b 100644
--- a/packages/data-schemas/src/schema/promptGroup.ts
+++ b/packages/data-schemas/src/schema/promptGroup.ts
@@ -63,6 +63,6 @@ const promptGroupSchema = new Schema(
},
);
-promptGroupSchema.index({ createdAt: 1, updatedAt: 1 });
+promptGroupSchema.index({ numberOfGenerations: -1, updatedAt: -1, _id: 1 });
export default promptGroupSchema;
diff --git a/packages/data-schemas/src/utils/index.ts b/packages/data-schemas/src/utils/index.ts
index a185a096eb..c071f4e827 100644
--- a/packages/data-schemas/src/utils/index.ts
+++ b/packages/data-schemas/src/utils/index.ts
@@ -2,3 +2,4 @@ export * from './principal';
export * from './string';
export * from './tempChatRetention';
export * from './transactions';
+export * from './objectId';
diff --git a/packages/data-schemas/src/utils/objectId.ts b/packages/data-schemas/src/utils/objectId.ts
new file mode 100644
index 0000000000..c368c47371
--- /dev/null
+++ b/packages/data-schemas/src/utils/objectId.ts
@@ -0,0 +1,2 @@
+/** Returns true when `id` is a 24-character hex string (MongoDB ObjectId format). */
+export const isValidObjectIdString = (id: string): boolean => /^[a-f\d]{24}$/i.test(id);