📁 refactor: Prompts UI (#11570)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* style: enhance prompts UI with new components and improved structure; add CreatePromptButton and AutoSendPrompt; refactor GroupSidePanel and PromptsAccordion

* refactor(Prompts): move button components to buttons/ subdirectory

* refactor(Prompts): move dialog components to dialogs/ subdirectory

* refactor(Prompts): move display components to display/ subdirectory

* refactor(Prompts): move editor components to editor/ subdirectory

* refactor(Prompts): move field components to fields/ subdirectory

* refactor(Prompts): move form components to forms/ subdirectory

* refactor(Prompts): move layout components to layouts/ subdirectory

* refactor(Prompts): move list components to lists/ subdirectory

* refactor(Prompts): move sidebar components to sidebar/ subdirectory

* refactor(Prompts): move utility components to utils/ subdirectory

* refactor(Prompts): update main exports and external imports

* refactor(Prompts): fix class name typo in AutoSendPrompt

* refactor(Prompts): reorganize exports and imports order across components

* refactor(Prompts): reorder exports for better organization and clarity

* refactor(Buttons): enhance prompts accessibility with aria-labels and update translations

* refactor(AdminSettings): reorganize imports and improve form structure for clarity

* refactor(Dialogs): reorganize imports for consistency and clarity across DeleteVersion, SharePrompt, and VariableDialog components

* refactor(Dialogs): enhance prompts accessibility with aria-labels

* refactor(Display): enhance prompt components and accessibility features

* refactor(.gitignore): add Playwright MCP directory

* refactor(Preview): enhance prompt components, improve layout, and add accessibility features

* refactor(Prompts): enhance variable handling, improve accessibility, and update UI components

* refactor(Prompts): enhance loading state handling and improve accessibility in PromptName component

* refactor(Prompts): streamline special variable handling, improve icon management, and enhance UI components

* refactor(Prompts): update AdvancedSwitch component to use Radio for mode selection, enhance PromptName with tooltips, and improve layout in PromptForm

* refactor(Prompts): enhance VersionCard and VersionBadge components for improved UI and accessibility, update loading state handling in VersionsPanel

* refactor(Prompts): improve layout and styling of VersionCard component for better visual alignment and clarity

* refactor(DeleteVersion): update text color for confirmation prompt in DeleteConfirmDialog

* refactor(Prompts): add configurations for always make production and auto-send prompts, update localization strings for clarity

* refactor(Prompts): enhance layout and styling in CategorySelector, CreatePromptForm, and List components for improved responsiveness and clarity

* refactor(Prompts): enhance PromptDetailHeader and ChatGroupItem components, add shared prompt indication, and remove unused PromptMetadata component

* refactor(Prompts): implement prompt group usage tracking, update sorting logic, and enhance related components

* fix(Prompts): security, performance, and pagination fixes

- Fix cursor pagination skipping/duplicating items by including
  numberOfGenerations in cursor condition to match sort order
- Close NoSQL injection vector via otherFilters rest spread in
  GET /all, GET /groups, and buildPromptGroupFilter
- Validate groupId as ObjectId before passing to query (GET /)
- Add prompt body validation in addPromptToGroup (type + text)
- Return 404 instead of 500 for missing group in POST /use
- Combine data + count into single $facet aggregation
- Add compound index {numberOfGenerations, updatedAt, _id}
- Add index on prompt.author for deleteUserPrompts
- Update useRecordPromptUsage to refresh client caches
- Replace console.error with logger.error

* refactor(PromptForm): remove console warning for unselected prompt in VersionsPanel

* refactor(Prompts): improve error handling for groupId and streamline usage tracking

* refactor(.gitignore): add CLAUDE.md to ignore list

* refactor(Prompts): streamline prompt components by removing unused variables and enhancing props structure

* refactor(Prompts): fix sort stability, keyboard handling, and remove dead code

Add _id tiebreaker to prompt group sort pipelines for deterministic
pagination ordering. Prevent default browser scroll on Space key in
PromptEditor preview mode. Remove unused blurTimeoutRef and its
onMutate callback from DashGroupItem.

* refactor(Prompts): enhance groupId validation and improve prompt group aggregation handling

* fix: aria-hidden, API fixes, accessibility improvements

* fix: ACL author filter, mobile guard, semantic HTML, and add useFocusTrap hook

- Remove author filter from patchPromptGroup so ACL-granted editors
  can update prompt groups (aligns with deletePromptGroupController)
- Add missing group guard to mobile HeaderActions in PromptForm
- Replace div with article in DashGroupItem, remove redundant
  stopPropagation and onClick on outer container
- Add useFocusTrap hook for keyboard focus management
- Add numberOfGenerations to default projection
- Deduplicate ObjectId validation, remove console.warn,
  fix aria-labelledby, localize search announcements

* refactor(Prompts): adjust UI and improve a11y

* refactor(Prompts): reorder imports for consistency and clarity

* refactor(Prompts): implement updateFieldsInPlace for efficient data updates and add related tests

* refactor(Prompts): reorder imports to include updateFieldsInPlace for better organization

* refactor(Prompts): enhance DashGroupItem with toast notifications for prompt updates and add click-to-edit functionality in PromptEditor

* style: use self-closing TooltipAnchor in CreatePromptButton

Replace ></TooltipAnchor> with /> for consistency with the rest of the Prompts directory.

* fix(i18n): replace placeholder text for com_ui_global_group translation key

The value was left as 'something needs to go here. was empty' which
would be visible to users as an aria-label in DashGroupItem.

* fix(DashGroupItem): sync rename input with group.name on external changes

nameInputValue was initialized via useState(group.name) but never
synced when group.name changed from a background refetch. Added
useEffect that updates the input when the dialog is closed.

* perf(useFocusTrap): store onEscape in ref to avoid listener churn

onEscape was in the useEffect dependency array, causing the keydown
listener to be torn down and re-attached on every render when callers
passed an inline function. Now stored in a ref so the effect only
re-runs when active or containerRef changes.

* fix(a11y): replace role=button div with layered button overlay in ListCard

The card used role='button' on a div that contained nested Button
elements — an invalid ARIA pattern. Replaced with a hidden button
at z-0 for the card action while child interactive elements sit
at z-10, eliminating nested interactive element violations.

* fix(PromptForm): reset selectionIndex on route change, guard auto-save, and fix a11y

- Reset selectionIndex to 0 and isEditing to false when promptId
  changes, preventing out-of-bounds index when navigating between
  groups with different version counts.
- Track selectedPrompt in a ref so the auto-save effect doesn't
  fire against a stale prompt when the selection changed mid-edit.
- Stabilize useFocusTrap onEscape via useCallback to avoid
  unnecessary listener re-attachment.
- Conditionally render mobile overlay instead of always-present
  button with aria-hidden/pointer-events toggling.

* refactor: extract isValidObjectIdString to shared utility in data-schemas

The same regex helper was duplicated in api/server/routes/prompts.js
and packages/data-schemas/src/methods/prompt.ts. Moved to
packages/data-schemas/src/utils/objectId.ts and imported from both
consumers. Also removed a duplicate router.use block introduced
during the extraction.

* perf(updateFieldsInPlace): replace JSON deep clone with targeted spread

Instead of JSON.parse(JSON.stringify(data)) which serializes the
entire paginated data structure, use targeted immutable spreads
that only copy the affected page and collection array. Returns the
original data reference unchanged when the item is not found.

* perf(VariablesDropdown): memoize items array and stabilize handleAddVariable

The items array containing JSX elements was rebuilt on every render.
Wrapped in useMemo keyed on usedVariables and localize. Also wrapped
handleAddVariable in useCallback and memoized usedCount to avoid
redundant array filtering.

* perf(DashGroupItem): stabilize mutation callbacks via refs

handleSaveRename and handleDelete had updateGroup/deleteGroup mutation
objects in their useCallback dependency arrays. Since mutation objects
are new references each render, the callbacks were recreated every
render, defeating memoization. Now store mutation objects in refs and
call via ref.current in the callbacks.

* fix(security): validate groupId in incrementPromptGroupUsage

The data-schema method passed the groupId string directly to
findByIdAndUpdate without validation. If called from a different
entrypoint without the route-level check, Mongoose would throw a
CastError. Now validates with isValidObjectIdString before the
DB call and throws a clean 'Invalid groupId' error.

* fix(security): add rate limiter to prompt usage tracking endpoint

POST /groups/:groupId/use had no rate limiting — a user could spam
it to inflate numberOfGenerations, which controls sort order for all
users. Added promptUsageLimiter (30 req/user/min) following the same
pattern as toolCallLimiter. Also handle 'Invalid groupId' error from
the data layer in the route error handler.

* fix(updateFieldsInPlace): guard against undefined identifier value

If updatedItem[identifierField] is null/undefined, findIndex could
match unintended items where that field is also undefined. Added
early return when the identifier value is nullish.

* fix(a11y): use React useId for stable unique IDs in ListCard

aria-describedby/id values were derived from prompt name which can
contain spaces and special characters, producing invalid HTML IDs
and potential collisions. Now uses React.useId() for guaranteed
unique, valid IDs per component instance.

* fix: Align prompts panel styling with other sidebar panels and fix test

- Match FilterPrompts first row to Memory/Bookmark pattern (items-center gap-2)
- Remove items-stretch override from PromptsAccordion
- Add missing promptUsageLimiter mock to prompts route test

* fix: Address code review findings for prompts refactor PR

- Fix #5: Gate DeletePrompt in HeaderActions behind canDelete permission
- Fix #8: BackToChat navigates to last conversation instead of /c/new
- Fix #7: Restore useLiveAnnouncer for screen reader feedback on delete/rename
- Fix #1: Use isPublic (set by API) instead of deprecated projectIds for globe icon
- Fix #4: Optimistic cache update in useRecordPromptUsage instead of full invalidation
- Fix #6: Add migration to drop superseded { createdAt, updatedAt } compound index
- Fix #9: Single-pass reduce in PromptVariables instead of triple filter
- Fix #10: Rename PromptLabelsForm internal component to avoid collision with PromptForm
- Fix #14: Remove redundant aria-label from aria-hidden Checkbox in AutoSendPrompt

* fix: Align prompts panel filter row element sizes with other panels

- Override Dropdown trigger to size-9 (36px) to match FilterInput height
- Set CreatePromptButton to size-9 shrink-0 bg-transparent matching
  Memory/Bookmark panel button pattern

* fix(prompts): Shared Prompts filter ignores direct shares, only returns PUBLIC

Folds fix from PR #11882 into the refactored codebase.

Bug A: filterAccessibleIdsBySharedLogic now accepts ownedPromptGroupIds:
- MY_PROMPTS: accessible intersect owned
- SHARED_PROMPTS: (accessible union public) minus owned
- ALL: accessible union public (deduplicated)
Legacy fallback preserved when ownedPromptGroupIds is omitted.

Bug B: getPromptGroup uses $lookup aggregation to populate productionPrompt,
fixing empty text on direct URL navigation to shared prompts.

Also adds getOwnedPromptGroupIds to data-schemas methods and passes it
from both /all and /groups route handlers.

* fix: Add missing canDelete to mobile HeaderActions, remove dead instanceProjectId prop

- Pass canDelete to mobile HeaderActions row (was only on desktop)
- Remove instanceProjectId prop from ChatGroupItem and DashGroupItem
  since global check now uses group.isPublic
- Remove useGetStartupConfig from List.tsx (no longer needed)

* fix: Use runtime ObjectId instead of type-only Types.ObjectId, fix i18next interpolation

- getPromptGroup and getOwnedPromptGroupIds were using Types.ObjectId
  (imported as type-only), which is erased at compile time. Use the
  runtime ObjectId from mongoose.Types (already destructured at line 20).
  This fixes the 404s in PATCH /groups/:groupId tests.
- Fix com_ui_prompt_deleted_group translation to use {{0}} (i18next
  double-brace syntax) instead of {0}.

* chore: Fix translation key ordering, add sideEffects: false to data-provider

- Reorder new translation keys to maintain alphabetical order:
  com_ui_click_to_edit, com_ui_labels, com_ui_live, com_ui_prompt_delete_confirm,
  com_ui_prompt_deleted_group, com_ui_prompt_details, com_ui_prompt_renamed,
  com_ui_prompt_update_error, com_ui_prompt_variables_list
- Add "sideEffects": false to librechat-data-provider package.json to
  enable tree-shaking of unused exports (types, constants, pure functions)

* fix: Reduce prompts panel spacing, align memory toggle with checkbox pattern

- Remove unnecessary wrapper div around AutoSendPrompt in PromptsAccordion,
  reducing vertical space between the toggle and the first prompt item
- Replace Memory panel's Switch toggle with Checkbox+Button pattern
  matching the prompts panel's AutoSendPrompt for visual consistency

* fix: Reduce gap between AutoSendPrompt and first prompt item

Change ChatGroupItem margin from my-2 to mb-2 to eliminate the
doubled spacing (gap-2 from parent + top margin from first item).
Restore wrapper div around AutoSendPrompt for right-alignment.

* fix: Restore prompt name on empty save, remove dead bodyProps from checkGlobalPromptShare

- PromptName: reset newName to name when save is cancelled due to empty
  or unchanged input, preventing blank title in read mode
- checkGlobalPromptShare: remove dead bodyProps config — Permissions.SHARE
  was not in the permissions array so the bodyProps rule was never evaluated.
  Per-resource share checks are handled by canAccessPromptGroupResource.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-03-22 21:56:22 +01:00 committed by GitHub
parent 676641f3da
commit ccd049d8ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 4319 additions and 2276 deletions

2
.gitignore vendored
View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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,

View file

@ -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 =

View file

@ -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(() => {

View file

@ -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,

View file

@ -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 (
<div className="relative flex h-10 items-center justify-center rounded-xl border border-border-light bg-surface-primary transition-all duration-300">
<div className="relative flex w-48 items-stretch md:w-64">
<div
className="absolute rounded-lg bg-surface-hover shadow-lg transition-all duration-300 ease-in-out"
style={{
top: '1px',
left: mode === PromptsEditorMode.SIMPLE ? '2px' : 'calc(50% + 2px)',
width: 'calc(50% - 4px)',
height: 'calc(100% - 2px)',
}}
/>
{/* Simple Mode Button */}
<button
type="button"
onClick={() => {
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'
}`}
>
<span className="relative">{localize('com_ui_simple')}</span>
</button>
{/* Advanced Mode Button */}
<button
type="button"
onClick={() => 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'
}`}
>
<span className="relative">{localize('com_ui_advanced')}</span>
</button>
</div>
</div>
);
};
export default AdvancedSwitch;

View file

@ -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<HTMLAnchorElement>) => {
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
event.preventDefault();
navigate('/c/new');
}
};
return (
<a
className={cn(buttonVariants({ variant: 'outline' }), className)}
href="/"
onClick={clickHandler}
>
<ArrowLeft className="icon-xs mr-2" aria-hidden="true" />
{localize('com_ui_back_to_chat')}
</a>
);
}

View file

@ -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<HTMLButtonElement | null>(null);
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
const text = group.productionPrompt?.prompt;
if (!text?.trim()) {
return;
}
if (detectVariables(text)) {
setVariableDialogOpen(true);
return;
}
submitPrompt(text);
};
return (
<>
<div className="relative my-2 items-stretch justify-between rounded-xl border border-border-light px-1 shadow-sm transition-all duration-300 ease-in-out hover:bg-surface-tertiary hover:shadow-lg">
<ListCard
name={group.name}
category={group.category ?? ''}
onClick={onCardClick}
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}
></ListCard>
{groupIsGlobal === true && (
<div className="absolute right-14 top-[16px]">
<EarthIcon
className="icon-md text-green-400"
aria-label={localize('com_ui_sr_global_prompt')}
/>
</div>
)}
<div className="absolute right-0 top-0 mr-1 mt-2.5 items-start pl-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
ref={triggerButtonRef}
id={`prompt-actions-${group._id}`}
type="button"
aria-label={localize('com_ui_sr_actions_menu', { 0: group.name })}
onClick={(e) => {
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"
>
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id={`prompt-menu-${group._id}`}
aria-label={`Available actions for ${group.name}`}
className="z-50 w-fit rounded-xl"
collisionPadding={2}
align="start"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
>
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_preview')}</span>
</DropdownMenuItem>
{canEdit && (
<DropdownMenuGroup>
<DropdownMenuItem
disabled={!canEdit}
className="cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
asChild
>
<Link to={`/d/prompts/${group._id}`}>
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<PreviewPrompt
group={group}
open={isPreviewDialogOpen}
onOpenChange={setPreviewDialogOpen}
onCloseAutoFocus={() => {
requestAnimationFrame(() => {
triggerButtonRef.current?.focus({ preventScroll: true });
});
}}
/>
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
</>
);
}
export default memo(ChatGroupItem);

View file

@ -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<NodeJS.Timeout | null>(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<HTMLButtonElement>) => {
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 (
<div
className={cn(
'relative mx-2 my-2 rounded-lg border border-border-light bg-surface-primary shadow-sm transition-all duration-300 ease-in-out hover:bg-surface-secondary',
params.promptId === group._id && 'bg-surface-hover',
)}
>
<button
type="button"
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-3 text-left"
onClick={handleContainerClick}
onKeyDown={handleKeyDown}
aria-label={
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,
})
}
>
<div className="flex items-center gap-2 truncate pr-2">
<CategoryIcon category={group.category ?? ''} className="icon-lg" aria-hidden="true" />
<Label className="text-md cursor-pointer truncate font-semibold text-text-primary">
{group.name}
</Label>
</div>
<div className="flex h-full items-center gap-2">
{isPublicGroup && (
<EarthIcon
className="icon-md text-green-500"
aria-label={localize('com_ui_global_group')}
/>
)}
</div>
</button>
<div className="absolute right-0 top-0 mr-1 mt-2.5 flex items-start gap-1 pl-2">
{canEdit && (
<OGDialog>
<OGDialogTrigger asChild>
<button
type="button"
onClick={(e) => 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_rename_prompt_name', { name: group.name })}
>
<Pen className="icon-sm text-text-primary" aria-hidden="true" />
</button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_rename_prompt')}
className="w-11/12 max-w-lg"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Input
value={nameInputValue}
onChange={(e) => setNameInputValue(e.target.value)}
className="w-full"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
/>
</div>
</div>
}
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,
}}
/>
</OGDialog>
)}
{canDelete && (
<OGDialog>
<OGDialogTrigger asChild>
<button
type="button"
onClick={(e) => 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 })}
>
<TrashIcon className="icon-sm text-text-primary" aria-hidden="true" />
</button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-lg"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="confirm-delete" className="text-left text-sm font-medium">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: group.name }}
components={{ strong: <strong /> }}
/>
</Label>
</div>
</div>
}
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'),
}}
/>
</OGDialog>
)}
</div>
</div>
);
}
export default memo(DashGroupItemComponent);

View file

@ -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 (
<div className="flex h-full flex-col">
{hasCreateAccess && (
<div className="flex w-full justify-end">
<Button
asChild
variant="outline"
className={cn('w-full bg-transparent', !isChatRoute && 'mx-2')}
aria-label={localize('com_ui_create_prompt')}
>
<Link to="/d/prompts/new">
<Plus className="size-4" aria-hidden="true" />
{localize('com_ui_create_prompt')}
</Link>
</Button>
</div>
)}
<div className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading &&
!isChatRoute &&
Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton key={index} className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4" />
))}
{!isLoading && groups.length === 0 && (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center',
isChatRoute ? 'my-2' : 'mx-2 my-4',
)}
>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<FileText className="size-5 text-text-secondary" aria-hidden="true" />
</div>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_prompts_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">
{localize('com_ui_add_first_prompt')}
</p>
</div>
)}
{groups.map((group) => {
if (isChatRoute) {
return <ChatGroupItem key={group._id} group={group} />;
}
return <DashGroupItem key={group._id} group={group} />;
})}
</div>
</div>
</div>
);
}

View file

@ -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<HTMLDivElement | HTMLButtonElement>;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement | HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick?.(event as unknown as React.MouseEvent<HTMLDivElement | HTMLButtonElement>);
}
};
return (
<div
onClick={onClick}
onKeyDown={handleKeyDown}
className="relative flex w-full cursor-pointer flex-col gap-2 rounded-xl px-3 pb-4 pt-3 text-start align-top text-[15px]"
role="button"
tabIndex={0}
aria-labelledby={`card-title-${name}`}
aria-describedby={`card-snippet-${name}`}
aria-label={`${name} Prompt, ${category ? `${localize('com_ui_category')}: ${category}` : ''}`}
>
<div className="flex w-full justify-between gap-2">
<div className="flex flex-row gap-2">
<CategoryIcon category={category} className="icon-md" aria-hidden="true" />
<Label
id={`card-title-${name}`}
className="break-word select-none text-balance text-sm font-semibold text-text-primary"
title={name}
>
{name}
</Label>
</div>
<div>{children}</div>
</div>
<div
id={`card-snippet-${name}`}
className="ellipsis max-w-full select-none text-balance pt-1 text-sm text-text-secondary"
>
{snippet}
</div>
</div>
);
}

View file

@ -1,45 +0,0 @@
import React from 'react';
import { handleDoubleClick } from '~/utils';
export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
return (
<code
onDoubleClick={handleDoubleClick}
className="rounded-md bg-surface-primary-alt p-1 text-xs text-text-secondary md:text-sm"
>
{children}
</code>
);
};
const regex = /{{(.*?)}}/g;
export const PromptVariableGfm = ({
children,
}: {
children: React.ReactNode & React.ReactNode[];
}) => {
const renderContent = (child: React.ReactNode) => {
if (typeof child === 'object' && child !== null) {
return child;
}
if (typeof child !== 'string') {
return child;
}
const parts = child.split(regex);
return parts.map((part, index) =>
index % 2 === 1 ? (
<b
key={index}
className="ml-[0.5] rounded-lg bg-amber-100 p-[1px] font-medium text-yellow-800 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90"
>
{`{{${part}}}`}
</b>
) : (
part
),
);
};
return <p>{React.Children.map(children, (child) => renderContent(child))}</p>;
};

View file

@ -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 (
<div>
<div className="flex flex-col items-center justify-between p-4 text-text-primary sm:flex-row">
<div className="mb-1 flex flex-row items-center font-bold sm:text-xl md:mb-0 md:text-2xl">
<div className="mb-1 flex items-center md:mb-0">
<div className="rounded pr-2">
{(group.category?.length ?? 0) > 0 ? (
<CategoryIcon category={group.category ?? ''} />
) : null}
</div>
<Label className="text-2xl font-bold">{group.name}</Label>
</div>
</div>
</div>
<div className="flex h-full max-h-screen flex-col overflow-y-auto md:flex-row">
<div className="flex flex-1 flex-col gap-4 p-0 md:max-h-[calc(100vh-150px)] md:p-2">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary">
{localize('com_ui_prompt_text')}
</h2>
<div className="group relative min-h-32 rounded-b-lg border border-border-light p-4 transition-all duration-150">
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="markdown prose dark:prose-invert light dark:text-gray-70 my-1 break-words"
>
{mainText}
</ReactMarkdown>
</div>
</div>
<PromptVariables promptText={mainText} showInfo={false} />
<Description initialValue={group.oneliner} disabled={true} />
<Command initialValue={group.command} disabled={true} />
</div>
</div>
</div>
);
};
export default PromptDetails;

View file

@ -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<React.SetStateAction<boolean>>;
};
const PromptEditor: React.FC<Props> = ({ 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 (
<div className="flex max-h-[85vh] flex-col sm:max-h-[85vh]">
<h2 className="sr-only">{localize('com_ui_control_bar')}</h2>
<div className="flex items-center justify-between rounded-t-xl border border-border-light py-1.5 pl-3 text-sm font-semibold text-text-primary sm:py-2 sm:pl-4 sm:text-base">
<span className="max-w-[200px] truncate sm:max-w-none">
{localize('com_ui_prompt_text')}
</span>
<div className="flex flex-shrink-0 flex-row items-center gap-3 sm:gap-6">
{editorMode === PromptsEditorMode.ADVANCED && (
<AlwaysMakeProd className="hidden sm:flex" />
)}
<VariablesDropdown fieldName={name} />
<button
type="button"
onClick={() => setIsEditing((prev) => !prev)}
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"
>
<EditorIcon
className={cn(
'h-5 w-5 sm:h-6 sm:w-6',
isEditing ? 'p-[0.05rem]' : 'text-secondary-alt hover:text-text-primary',
)}
/>
</button>
</div>
</div>
<div
role="button"
className={cn(
'w-full flex-1 overflow-auto rounded-b-xl border border-border-light p-2 shadow-md transition-all duration-150 sm:p-4',
{
'cursor-pointer bg-surface-primary hover:bg-surface-secondary active:bg-surface-tertiary':
!isEditing,
},
)}
onClick={() => !isEditing && setIsEditing(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
!isEditing && setIsEditing(true);
}
}}
tabIndex={0}
>
{!isEditing && (
<EditIcon className="icon-xl absolute inset-0 m-auto hidden h-6 w-6 text-text-primary opacity-25 group-hover:block sm:h-8 sm:w-8" />
)}
<Controller
name={name}
control={control}
render={({ field }) =>
isEditing ? (
<TextareaAutosize
{...field}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="w-full resize-none overflow-y-auto rounded bg-transparent text-sm text-text-primary focus:outline-none sm:text-base"
minRows={3}
maxRows={14}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
setIsEditing(false);
}
}}
aria-label={localize('com_ui_prompt_input')}
/>
) : (
<div
className={cn('overflow-y-auto text-sm sm:text-base')}
style={{ minHeight: '4.5em', maxHeight: '21em', overflow: 'auto' }}
>
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="markdown prose dark:prose-invert light my-1 w-full break-words text-text-primary"
>
{field.value}
</ReactMarkdown>
</div>
)
}
/>
</div>
</div>
);
};
export default memo(PromptEditor);

View file

@ -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<React.SetStateAction<number>>;
}
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 (
<div
className="h-full w-full overflow-y-auto bg-surface-primary px-4"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
<CategorySelector
currentCategory={groupCategory}
onValueChange={
canEdit
? (value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
: undefined
}
/>
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && canEdit && (
<Button
variant="submit"
size="sm"
aria-label="Make prompt production"
className="h-10 w-10 border border-transparent p-0.5 transition-all"
onClick={() => {
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
}
>
<Rocket className="size-5 cursor-pointer text-white" aria-hidden="true" />
</Button>
)}
<DeleteVersion
promptId={selectedPromptId}
groupId={groupId}
promptName={groupName}
disabled={isLoadingGroup}
/>
</div>
</div>
{editorMode === PromptsEditorMode.ADVANCED &&
(isLoadingPrompts
? Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
<Skeleton className="h-[72px] w-full" />
</div>
))
: prompts.length > 0 && (
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
))}
</div>
);
},
);
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<number>(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 <SkeletonForm />;
}
// 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 <NoPromptGroup />;
}
if (fetchedPrompt || group) {
return <PromptDetails group={fetchedPrompt || group} />;
}
}
if (!group || group._id == null) {
return null;
}
const groupName = group.name;
return (
<FormProvider {...methods}>
<form className="mt-4 flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<h1 className="sr-only">{localize('com_ui_edit_prompt_page')}</h1>
<div className="relative w-full">
<div
className="h-full w-full"
style={{
transform: `translateX(${showSidePanel ? `-${sidePanelWidth}` : '0'})`,
transition: 'transform 0.3s ease-in-out',
}}
>
<div className="flex h-full">
<div className="flex-1 overflow-hidden px-4">
<div className="mb-4 flex items-center gap-2 text-text-primary">
{isLoadingGroup ? (
<Skeleton className="mb-1 flex h-10 w-32 font-bold sm:text-xl md:mb-0 md:h-12 md:text-2xl" />
) : (
<>
<PromptName
name={groupName}
onSave={(value) => {
if (!canEdit || !group._id) {
return;
}
updateGroupMutation.mutate({
id: group._id,
payload: { name: value },
});
}}
/>
<div className="flex-1" />
<Button
type="button"
variant="ghost"
className="h-10 w-10 border border-border-light p-0 lg:hidden"
onClick={() => setShowSidePanel(true)}
aria-label={localize('com_endpoint_open_menu')}
>
<Menu className="size-5" aria-hidden="true" />
</Button>
<div className="hidden lg:block">
{editorMode === PromptsEditorMode.SIMPLE && (
<RightPanel
group={group}
prompts={prompts}
selectedPrompt={selectedPrompt}
selectionIndex={selectionIndex}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
)}
</div>
</>
)}
</div>
{isLoadingPrompts ? (
<Skeleton className="h-96" aria-live="polite" />
) : (
<div className="mb-2 flex h-full flex-col gap-4">
<PromptEditor
name="prompt"
isEditing={isEditing}
setIsEditing={(value) => canEdit && setIsEditing(value)}
/>
<PromptVariables promptText={promptText} />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={canEdit ? handleUpdateOneliner : undefined}
disabled={!canEdit}
/>
<Command
initialValue={group.command ?? ''}
onValueChange={canEdit ? handleUpdateCommand : undefined}
disabled={!canEdit}
/>
</div>
)}
</div>
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-1/4 border-l border-border-light lg:block">
<RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
)}
</div>
</div>
<button
type="button"
className={cn(
'absolute inset-0 z-40 cursor-default',
showSidePanel ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: 'opacity 0.3s ease-in-out' }}
onClick={() => setShowSidePanel(false)}
aria-hidden={!showSidePanel}
tabIndex={showSidePanel ? 0 : -1}
aria-label={localize('com_ui_close_menu')}
/>
<div
className="absolute inset-y-0 right-0 z-50 lg:hidden"
style={{
width: sidePanelWidth,
transform: `translateX(${showSidePanel ? '0' : '100%'})`,
transition: 'transform 0.3s ease-in-out',
}}
role="dialog"
aria-modal="true"
aria-label="Mobile navigation panel"
>
<div className="h-full">
<div className="h-full overflow-auto">
<RightPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
</div>
</div>
</div>
</form>
</FormProvider>
);
};
export default PromptForm;

View file

@ -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<Props> = ({ name, onSave }) => {
const inputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<NodeJS.Timeout>();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const handleEditClick = () => {
setIsEditing(true);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewName(e.target.value);
};
const saveName = () => {
const savedName = newName?.trim();
onSave(savedName || '');
setIsEditing(false);
};
const handleSaveClick: React.MouseEventHandler<HTMLButtonElement> = () => {
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 (
<div className="flex items-center">
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
alignItems: 'center',
}}
className="gap-2"
>
{isEditing ? (
<>
<Input
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
ref={inputRef}
className="flex w-full max-w-none rounded-lg text-2xl font-bold transition duration-200"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
/>
<Button
onClick={handleSaveClick}
variant="ghost"
size="sm"
className="h-10 flex-shrink-0"
aria-label="Save prompt name"
>
<SaveIcon className="icon-md" />
</Button>
</>
) : (
<>
<Label
className="text-2xl font-bold"
style={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{newName}
</Label>
<Button
onClick={handleEditClick}
variant="ghost"
size="sm"
aria-label="Edit prompt name"
className="h-10 flex-shrink-0"
>
<EditIcon className="icon-md" />
</Button>
</>
)}
</div>
</div>
);
};
export default PromptName;

View file

@ -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 (
<div className="rounded-xl border border-border-light bg-transparent p-4 shadow-md">
<h3 className="flex items-center gap-2 py-2 text-lg font-semibold text-text-primary">
<Variable className="icon-sm" aria-hidden="true" />
{localize('com_ui_variables')}
</h3>
<div className="flex flex-col space-y-4">
{variables.length ? (
<div className="flex flex-wrap gap-2">
{variables.map((variable, index) => (
<span
className={cn(
'rounded-full border border-border-light px-3 py-1 text-text-primary',
specialVariables[variable.toLowerCase()] != null ? specialVariableClasses : '',
)}
key={index}
>
{specialVariables[variable.toLowerCase()] != null
? variable.toLowerCase()
: variable}
</span>
))}
</div>
) : (
<div className="text-sm text-text-secondary">
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_variables_info')}
</ReactMarkdown>
</div>
)}
<Separator className="my-3 text-text-primary" />
{showInfo && (
<div className="space-y-4">
<div>
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_special_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_special_variables_more_info')}
</ReactMarkdown>
</span>
</div>
<div>
<span className="text-sm font-medium text-text-primary">
{localize('com_ui_dropdown_variables')}
</span>
<span className="break-words text-sm text-text-secondary">
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_dropdown_variables_info')}
</ReactMarkdown>
</span>
</div>
</div>
)}
</div>
</div>
);
};
export default PromptVariables;

View file

@ -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 }) => (
<TooltipAnchor
description={description}
aria-label={description}
render={
<div className="flex items-center justify-center">
<Crown className="h-4 w-4 text-amber-500" />
</div>
}
></TooltipAnchor>
);
const VersionTags = ({ tags }: { tags: string[] }) => {
const localize = useLocalize();
const isLatestAndProduction = tags.includes('latest') && tags.includes('production');
if (isLatestAndProduction) {
return (
<span className="absolute bottom-3 right-3">
<CombinedStatusIcon description={localize('com_ui_latest_production_version')} />
</span>
);
}
return (
<span className="flex gap-1 text-sm">
{tags.map((tag, i) => (
<TooltipAnchor
description={
tag === 'production'
? localize('com_ui_currently_production')
: localize('com_ui_latest_version')
}
key={`${tag}-${i}`}
aria-label={
tag === 'production'
? localize('com_ui_currently_production')
: localize('com_ui_latest_version')
}
render={
<Tag
label={tag}
className={cn(
'w-24 justify-center border border-transparent',
tag === 'production'
? 'bg-green-100 text-green-700 dark:border-green-400 dark:bg-transparent dark:text-green-400'
: 'bg-blue-100 text-blue-700 dark:border-blue-400 dark:bg-transparent dark:text-blue-400',
)}
labelClassName="flex items-center m-0 justify-center gap-1"
LabelNode={(() => {
if (tag === 'production') {
return (
<div className="flex items-center">
<span className="slow-pulse size-2 rounded-full bg-green-400" />
</div>
);
}
if (tag === 'latest') {
return (
<div className="flex items-center">
<Zap className="size-4" />
</div>
);
}
return null;
})()}
/>
}
></TooltipAnchor>
))}
</span>
);
};
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 (
<button
type="button"
className={cn(
'group relative w-full rounded-lg border border-border-light p-4 transition-all duration-300',
isSelected
? 'bg-surface-secondary shadow-xl ring-2 ring-gray-400'
: 'bg-surface-primary shadow-sm hover:bg-surface-secondary',
)}
onClick={onClick}
aria-selected={isSelected}
role="tab"
aria-label={localize('com_ui_version_var', { 0: `${totalVersions - index}` })}
>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between lg:flex-col xl:flex-row">
<h3 className="font-bold text-text-primary">
{localize('com_ui_version_var', { 0: `${totalVersions - index}` })}
</h3>
<time className="text-xs text-text-secondary" dateTime={prompt.createdAt}>
{format(new Date(prompt.createdAt), 'yyyy-MM-dd HH:mm')}
</time>
</div>
<div className="flex items-center gap-1 lg:flex-col xl:flex-row">
{authorName && (
<Label className="text-left text-xs text-text-secondary">
{localize('com_ui_by_author', { 0: authorName })}
</Label>
)}
{tags.length > 0 && <VersionTags tags={tags} />}
</div>
</div>
</button>
);
};
const PromptVersions = ({
prompts,
group,
selectionIndex,
setSelectionIndex,
}: {
prompts: TPrompt[];
group?: TPromptGroup;
selectionIndex: number;
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
}) => {
const localize = useLocalize();
return (
<section className="my-6" aria-label="Prompt Versions">
<header className="mb-6">
<h2 className="flex items-center gap-2 text-base font-semibold text-text-primary">
<Layers3 className="h-5 w-5 text-green-500" />
{localize('com_ui_versions')}
</h2>
</header>
<div className="flex flex-col gap-3" role="tablist" aria-label="Version history">
{prompts.map((prompt: TPrompt, index: number) => {
const tags: string[] = [];
if (index === 0) {
tags.push('latest');
}
if (prompt._id === group?.productionId) {
tags.push('production');
}
return (
<VersionCard
key={prompt._id}
prompt={prompt}
index={index}
isSelected={index === selectionIndex}
totalVersions={prompts.length}
onClick={() => setSelectionIndex(index)}
authorName={group?.authorName}
tags={tags}
/>
);
})}
</div>
</section>
);
};
export default PromptVersions;

View file

@ -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 (
<div className="flex h-auto w-full flex-col px-3 pb-3">
<PromptSidePanel
className="h-auto space-y-2 md:mr-0 md:min-w-0 lg:w-full xl:w-full"
{...groupsNav}
>
<FilterPrompts className="items-stretch" />
<div className="flex w-full flex-row items-center justify-end">
<AutoSendPrompt className="text-xs dark:text-white" />
</div>
</PromptSidePanel>
</div>
);
}

View file

@ -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 (
<div className={className} title={localize('com_ui_add_special_variables')}>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label={localize('com_ui_add_special_variables')}
className="flex h-8 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<PlusCircle className="mr-1 h-3 w-3 text-text-secondary" aria-hidden={true} />
{localize('com_ui_special_variables')}
</Menu.MenuButton>
}
items={variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}))}
menuId={menuId}
className="z-30"
/>
</div>
);
}

View file

@ -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 = () => {
<Button
size="sm"
variant="outline"
className="mr-2 h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary sm:m-0"
aria-label={localize('com_ui_admin')}
className="mr-2 h-10 w-fit sm:m-0"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
@ -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}

View file

@ -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: <Sparkles className="size-3.5" />,
},
{
value: PromptsEditorMode.ADVANCED,
label: localize('com_ui_advanced'),
icon: <Layers className="size-3.5" />,
},
],
[localize],
);
const handleChange = useCallback(
(value: string) => {
if (value === PromptsEditorMode.SIMPLE) {
setAlwaysMakeProd(true);
}
setMode(value as PromptsEditorMode);
},
[setMode, setAlwaysMakeProd],
);
return (
<Radio
options={options}
value={mode}
onChange={handleChange}
className="border border-border-light"
/>
);
};
export default AdvancedSwitch;

View file

@ -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<boolean>(store.autoSendPrompts);
const localize = useLocalize();
@ -22,20 +19,21 @@ export default function AutoSendPrompt({
};
return (
<div
className={cn(
'flex select-none items-center justify-end gap-2 text-right text-sm',
className,
)}
<Button
size="sm"
variant="outline"
onClick={() => handleCheckedChange(!autoSendPrompts)}
aria-label={localize('com_nav_auto_send_prompts')}
aria-pressed={autoSendPrompts}
className={autoSendPrompts ? 'bg-surface-hover hover:bg-surface-hover' : ''}
>
<div> {localize('com_nav_auto_send_prompts')} </div>
<Switch
aria-label={localize('com_nav_auto_send_prompts')}
id="autoSendPrompts"
<Checkbox
checked={autoSendPrompts}
onCheckedChange={handleCheckedChange}
data-testid="autoSendPrompts"
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2"
/>
</div>
{localize('com_nav_auto_send_prompts')}
</Button>
);
}

View file

@ -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 (
<a
className={cn(buttonVariants({ variant: 'outline' }), className)}
href={href}
onClick={clickHandler}
>
<ArrowLeft className="icon-xs mr-2" aria-hidden="true" />
{localize('com_ui_back_to_chat')}
</a>
);
}

View file

@ -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 (
<TooltipAnchor
description={localize('com_ui_create_prompt')}
side="bottom"
render={
<Button
asChild
variant="outline"
size="icon"
className="size-9 shrink-0 bg-transparent"
aria-label={localize('com_ui_create_prompt')}
>
<Link to="/d/prompts/new">
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>
}
/>
);
}

View file

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

View file

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

View file

@ -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 (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
aria-label="Delete version"
className="h-10 w-10 p-0.5"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
}}
>
<Trash2 className="size-5 cursor-pointer text-white" aria-hidden="true" />
</Button>
<TooltipAnchor
description={localize('com_ui_delete')}
side="bottom"
render={
<Button
variant="destructive"
size="icon"
aria-label={localize('com_ui_delete')}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
}}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_prompt')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-delete-confirm-prompt"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm_prompt_version_var', { 0: name })}
</Label>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<p className="text-left text-sm text-text-primary">
{localize('com_ui_delete_confirm_prompt_version_var', { 0: name })}
</p>
</div>
</>
</div>
}
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({

View file

@ -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 (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent
className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]"
className="w-11/12 max-w-5xl overflow-hidden"
onCloseAutoFocus={onCloseAutoFocus}
>
<div>
<PromptDetails group={group} />
</div>
<OGDialogTitle className="sr-only">{localize('com_ui_preview')}</OGDialogTitle>
<PromptDetails group={group} onUsePrompt={() => onOpenChange(false)} />
</OGDialogContent>
</OGDialog>
);

View file

@ -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}
>
<Button
variant="default"
size="sm"
aria-label="Share prompt"
className="h-10 w-10 border border-transparent bg-blue-500/90 p-0.5 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
disabled={disabled}
>
<Share2Icon className="size-5 cursor-pointer text-white" aria-hidden="true" />
</Button>
<TooltipAnchor
description={localize('com_ui_share')}
side="bottom"
render={
<Button
variant="outline"
size="icon"
aria-label={localize('com_ui_share')}
disabled={disabled}
>
<Share2Icon className="size-4" aria-hidden="true" />
</Button>
}
/>
</GenericGrantAccessDialog>
);
},

View file

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

View file

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

View file

@ -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 (
<>
<div className="flex w-full gap-2 sm:justify-end sm:gap-3">
<SharePrompt group={group} disabled={false} />
<Button
variant="submit"
onClick={handleUsePrompt}
className="flex-1 gap-2 sm:min-w-40 sm:flex-none"
aria-label={localize('com_ui_use_prompt')}
>
<Send className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_use_prompt')}
</Button>
</div>
<VariableDialog open={showVariableDialog} onClose={handleVariableDialogClose} group={group} />
</>
);
};
export default PromptActions;

View file

@ -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 (
<div className="flex flex-col gap-3 py-2 sm:flex-row sm:items-center sm:gap-4">
{group.category && (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-surface-secondary">
<CategoryIcon category={group.category} className="h-6 w-6" />
</div>
)}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-xl font-bold text-text-primary" title={group.name}>
{group.name}
</h2>
{isGlobalGroup && (
<TooltipAnchor
description={localize('com_ui_sr_global_prompt')}
side="top"
render={
<EarthIcon
className="h-5 w-5 shrink-0 text-green-400"
aria-label={localize('com_ui_sr_global_prompt')}
/>
}
/>
)}
</div>
{group.oneliner && (
<p className="text-sm text-text-secondary sm:truncate">{group.oneliner}</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-text-secondary">
{isSharedPrompt && (
<span className="flex items-center gap-1">
<User className="h-3 w-3 text-text-secondary" aria-hidden="true" />
{localize('com_ui_by_author', { 0: group.authorName })}
</span>
)}
{formattedDate && (
<time className="flex items-center gap-1" dateTime={group.createdAt?.toString()}>
<Calendar className="h-3 w-3" aria-hidden="true" />
{formattedDate}
</time>
)}
{group.numberOfGenerations != null && group.numberOfGenerations > 0 && (
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" aria-hidden="true" />
{localize('com_ui_usage')}: {group.numberOfGenerations}
</span>
)}
</div>
</div>
</div>
);
};
export default PromptDetailHeader;

View file

@ -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 (
<article
className="flex max-h-[80vh] min-w-0 flex-col gap-3 overflow-hidden p-1 sm:gap-4 sm:p-2"
aria-label={localize('com_ui_prompt_details', { name: group.name })}
>
<h1 className="sr-only">{group.name}</h1>
<PromptDetailHeader group={group} />
<div className="min-h-0 flex-1">
<PromptTextCard mainText={mainText} />
</div>
<PromptVariables promptText={mainText} />
{group.command && (
<div className="flex items-center gap-2 rounded-xl border border-border-light bg-surface-secondary p-3">
<SquareSlash className="h-4 w-4 text-text-secondary" aria-hidden="true" />
<span className="font-mono text-sm text-text-primary">/{group.command}</span>
</div>
)}
{showActions && <PromptActions group={group} mainText={mainText} onUsePrompt={onUsePrompt} />}
</article>
);
};
export default PromptDetails;

View file

@ -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<NodeJS.Timeout | null>(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 (
<div className="flex h-full flex-col rounded-xl border border-border-light bg-transparent shadow-md">
<header className="flex shrink-0 items-center justify-between border-b border-border-light p-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-text-secondary" aria-hidden="true" />
<h3 className="text-base font-semibold text-text-primary">
{localize('com_ui_prompt_text')}
</h3>
</div>
<TooltipAnchor
description={isCopied ? localize('com_ui_copied') : localize('com_ui_copy')}
render={
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className="size-8 gap-1.5"
aria-label={
isCopied ? localize('com_ui_copied') : localize('com_ui_copy_to_clipboard')
}
aria-live="polite"
>
{isCopied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</Button>
}
/>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="markdown prose dark:prose-invert light my-1 max-w-none break-words text-text-primary"
>
{mainText}
</ReactMarkdown>
</div>
</div>
);
};
export default PromptTextCard;

View file

@ -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 (
<div
className="bg-surface-secondary/50 rounded-lg border border-border-light p-2.5 hover:bg-surface-secondary"
role="listitem"
aria-label={localize('com_ui_variable_with_options', {
name: parsed.name,
count: parsed.options.length,
})}
>
<div className="mb-2 flex items-center gap-2">
<div className="flex size-6 items-center justify-center rounded-md bg-surface-tertiary">
<ChevronRight className="size-3.5 text-text-secondary" aria-hidden="true" />
</div>
<span className="text-sm font-medium text-text-primary">{parsed.name}</span>
<span className="rounded-full bg-surface-tertiary px-1.5 py-0.5 text-[10px] font-medium text-text-secondary">
{parsed.options.length} {localize('com_ui_options')}
</span>
</div>
<div
className="flex flex-wrap gap-1.5"
role="list"
aria-label={localize('com_ui_available_options')}
>
{parsed.options.map((option, index) => (
<span
key={index}
className="rounded-md border border-border-light bg-surface-primary px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-surface-secondary"
role="listitem"
>
{option}
</span>
))}
</div>
</div>
);
};
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 (
<div
className="group flex items-start gap-2 rounded-lg border border-border-light bg-transparent p-2 hover:bg-surface-secondary"
role="listitem"
aria-label={displayLabel}
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-surface-tertiary">
<Icon className="size-3.5 text-text-secondary" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-text-primary">{displayLabel}</span>
{description && <p className="mt-0.5 text-[11px] text-text-secondary">{description}</p>}
</div>
</div>
);
};
const SimpleVariableChip = ({ parsed }: { parsed: ParsedVariable }) => (
<span
className="bg-surface-secondary/50 inline-flex items-center gap-1.5 rounded-lg border border-border-light px-2.5 py-1.5 text-xs font-medium text-text-primary hover:bg-surface-tertiary"
role="listitem"
>
<Variable className="size-3 text-text-secondary" aria-hidden="true" />
<span className="max-w-32 truncate">{parsed.name}</span>
</span>
);
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 (
<div className="overflow-hidden rounded-xl border border-border-light">
<header className="flex items-center justify-between border-b border-border-light p-3">
<div className="flex items-center gap-2">
<Variable className="size-4 text-text-secondary" aria-hidden="true" />
<h4 className="text-sm font-semibold text-text-primary">
{localize('com_ui_variables')}
</h4>
</div>
<span className="flex size-6 items-center justify-center rounded-full bg-surface-tertiary text-xs font-medium tabular-nums text-text-secondary">
{variables.length}
</span>
</header>
<div
className="flex flex-col gap-4 p-3"
role="list"
aria-label={localize('com_ui_prompt_variables_list')}
>
{specialVars.length > 0 && (
<section aria-label={localize('com_ui_special_variables')}>
<h5 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_special_variables')}
</h5>
<div className="grid gap-2 sm:grid-cols-2">
{specialVars.map((parsed, index) => (
<SpecialVariableChip key={`special-${index}`} parsed={parsed} />
))}
</div>
</section>
)}
{dropdownVariables.length > 0 && (
<section aria-label={localize('com_ui_dropdown_variables')}>
<h5 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_dropdown_variables')}
</h5>
<div className="flex flex-col gap-2">
{dropdownVariables.map((parsed, index) => (
<DropdownVariableCard key={`dropdown-${index}`} parsed={parsed} />
))}
</div>
</section>
)}
{simpleVariables.length > 0 && (
<section aria-label={localize('com_ui_text_variables')}>
<h5 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_text_variables')}
</h5>
<div className="flex flex-wrap gap-2">
{simpleVariables.map((parsed, index) => (
<SimpleVariableChip key={`simple-${index}`} parsed={parsed} />
))}
</div>
</section>
)}
</div>
</div>
);
};
export default PromptVariables;

View file

@ -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 (
<TooltipAnchor
description={tooltip}
side="left"
render={
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
isProduction
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
)}
>
{isProduction ? (
<>
<span className="slow-pulse size-1.5 rounded-full bg-green-500" />
<span>{label}</span>
</>
) : (
<>
<Zap className="size-3" aria-hidden="true" />
<span>{label}</span>
</>
)}
</span>
}
/>
);
};
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 (
<li className="relative flex items-stretch" aria-current={isSelected ? 'true' : undefined}>
{/* Timeline connector */}
<div className="relative flex w-6 shrink-0 justify-center">
{/* Vertical line - extends through mb-2 gap + mt-3 to reach next circle */}
{index < totalVersions - 1 && (
<div className="absolute -bottom-5 top-0 w-0.5 bg-border-light" />
)}
{/* Circle marker - mt-3 aligns with card title (card has p-3) */}
<div
className={cn(
'relative z-10 mt-3 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
getTimelineConnectorClasses(isSelected, isProduction),
)}
>
{isSelected ? (
<CheckCircle2 className="size-3" aria-hidden="true" />
) : (
<Circle className="size-2" fill="currentColor" aria-hidden="true" />
)}
</div>
</div>
{/* Card content */}
<button
type="button"
className={cn(
'group mb-2 ml-2 flex flex-1 flex-col rounded-lg border p-3 text-left',
isSelected
? 'border-green-500/50 bg-green-50/50 dark:bg-green-950/20'
: 'border-border-light bg-surface-primary hover:border-border-medium hover:bg-surface-hover',
)}
onClick={onClick}
aria-label={localize('com_ui_version_var', { 0: `${versionNumber}` })}
>
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'text-sm font-semibold',
isSelected ? 'text-green-700 dark:text-green-400' : 'text-text-primary',
)}
>
{localize('com_ui_version_var', { 0: versionNumber })}
</span>
<div className="flex items-center gap-1">
{isProduction && (
<VersionBadge
type="production"
tooltip={localize('com_ui_currently_production')}
label={localize('com_ui_live')}
/>
)}
{isLatest && !isProduction && (
<VersionBadge
type="latest"
tooltip={localize('com_ui_latest_version')}
label={localize('com_ui_latest')}
/>
)}
</div>
</div>
<time
className="mt-1 text-xs text-text-secondary"
dateTime={prompt.createdAt}
title={new Date(prompt.createdAt).toLocaleString()}
>
{formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })}
</time>
</button>
</li>
);
};
const PromptVersions = ({
prompts,
group,
selectionIndex,
setSelectionIndex,
}: {
prompts: TPrompt[];
group?: TPromptGroup;
selectionIndex: number;
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
}) => {
const localize = useLocalize();
return (
<ul className="flex flex-col" aria-label={localize('com_ui_versions')}>
{prompts.map((prompt: TPrompt, index: number) => {
const isLatest = index === 0;
const isProduction = prompt._id === group?.productionId;
return (
<VersionCard
key={prompt._id}
prompt={prompt}
index={index}
isSelected={index === selectionIndex}
totalVersions={prompts.length}
onClick={() => setSelectionIndex(index)}
isLatest={isLatest}
isProduction={isProduction}
/>
);
})}
</ul>
);
};
export default PromptVersions;

View file

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

View file

@ -0,0 +1,65 @@
import React from 'react';
import { handleDoubleClick } from '~/utils';
export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
return (
<code
onDoubleClick={handleDoubleClick}
className="rounded-md bg-surface-primary-alt p-1 text-xs text-text-secondary md:text-sm"
>
{children}
</code>
);
};
const variableRegex = /{{(.*?)}}/g;
const highlightVariables = (text: string): React.ReactNode[] => {
const parts = text.split(variableRegex);
return parts.map((part, index) => {
if (index % 2 === 1) {
return (
<b
key={index}
className="ml-[0.5] rounded-lg bg-amber-100 p-[1px] font-medium text-text-warning dark:bg-transparent"
>
{`{{${part}}}`}
</b>
);
}
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) => (
<React.Fragment key={index}>{processChildren(child)}</React.Fragment>
));
}
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 <p>{processChildren(children)}</p>;
};

View file

@ -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<React.SetStateAction<boolean>>;
};
const PromptEditor: React.FC<Props> = ({ 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 (
<div className="flex max-h-[85vh] flex-col sm:max-h-[85vh]">
<h2 className="sr-only">{localize('com_ui_control_bar')}</h2>
<header className="flex items-center justify-between rounded-t-xl border border-border-light bg-transparent p-2">
<div className="ml-1 flex items-center gap-2">
<FileText className="size-4 text-text-secondary" aria-hidden="true" />
<h3 className="text-sm font-semibold text-text-primary">
{localize('com_ui_prompt_text')}
</h3>
</div>
<div className="flex shrink-0 items-center gap-2">
<VariablesDropdown fieldName={name} />
<TooltipAnchor
description={isEditing ? localize('com_ui_save') : localize('com_ui_edit')}
render={
<Button
type="button"
size="icon"
variant="ghost"
onMouseDown={(e) => 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"
>
<EditorIcon className="size-4 text-text-secondary" aria-hidden="true" />
</Button>
}
/>
</div>
</header>
<div
className={cn(
'relative w-full flex-1 overflow-auto rounded-b-xl border border-t-0 border-border-light p-3 text-left transition-all duration-200 sm:p-4',
isEditing
? 'bg-surface-primary'
: 'cursor-pointer bg-surface-primary hover:bg-surface-secondary',
)}
>
{!isEditing && (
<button
type="button"
aria-label={localize('com_ui_edit')}
className="absolute inset-0 z-0 rounded-b-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
onClick={() => setIsEditing(true)}
/>
)}
<Controller
name={name}
control={control}
render={({ field }) =>
isEditing ? (
<TextareaAutosize
{...field}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="w-full resize-none overflow-y-auto bg-transparent font-mono text-sm leading-relaxed text-text-primary placeholder:text-text-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary sm:text-base"
minRows={4}
maxRows={16}
onBlur={() => 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')}
/>
) : (
<div
className="group/preview relative min-h-[6rem] overflow-y-auto text-sm sm:text-base"
style={{ maxHeight: '24rem' }}
onClick={() => setIsEditing(true)}
>
{!field.value ? (
<p className="italic text-text-tertiary">{localize('com_ui_click_to_edit')}</p>
) : (
<ReactMarkdown
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="markdown prose dark:prose-invert light w-full break-words text-text-primary"
>
{field.value}
</ReactMarkdown>
)}
<div className="bg-surface-secondary/0 group-hover/preview:bg-surface-secondary/50 pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-200 group-hover/preview:opacity-100">
<div className="flex items-center gap-2 rounded-lg bg-surface-primary px-3 py-1.5 shadow-md">
<EditIcon className="size-4 text-text-secondary" aria-hidden="true" />
<span className="text-sm font-medium text-text-secondary">
{localize('com_ui_click_to_edit')}
</span>
</div>
</div>
</div>
)
}
/>
</div>
</div>
);
};
export default memo(PromptEditor);

View file

@ -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<keyof typeof specialVariables>;
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: <Icon className="size-4" />,
render: (
<div className="flex w-full items-start gap-2.5 py-0.5">
<div
className={`flex size-7 shrink-0 items-center justify-center rounded-md ${iconClass}`}
>
{isUsed ? (
<Check className="size-3.5" aria-hidden="true" />
) : (
<Icon className="size-3.5" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1">
<span className={`text-sm font-medium ${labelClass}`}>{localize(labelKey)}</span>
<p className="mt-0.5 text-xs text-text-secondary">{localize(descKey)}</p>
</div>
</div>
),
};
}),
[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 (
<div className={className}>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label={localize('com_ui_add_special_variables')}
className={`group flex h-8 items-center gap-1.5 rounded-lg bg-transparent px-2 text-sm ${buttonClass}`}
>
<Sparkles className="size-3.5 text-text-secondary" aria-hidden="true" />
<span className="hidden text-xs font-medium sm:inline">
{localize('com_ui_special_variables')}
</span>
{usedCount > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-surface-tertiary text-[10px] font-medium text-text-secondary">
{usedCount}
</span>
)}
<ChevronDown
className={`size-3 transition-transform duration-200 ${isMenuOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</Menu.MenuButton>
}
items={items}
menuId={menuId}
className="z-50 w-64"
itemClassName="px-2 py-1"
/>
</div>
);
}

View file

@ -0,0 +1,3 @@
export { default as PromptEditor } from './PromptEditor';
export { CodeVariableGfm, PromptVariableGfm } from './Markdown';
export { default as VariablesDropdown } from './VariablesDropdown';

View file

@ -75,12 +75,11 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
<Ariakit.MenuButton
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',
'w-fit gap-2',
'gap-2 sm:w-fit',
className,
)}
onClick={() => setIsOpen(!isOpen)}
aria-label="Prompt's category selector"
aria-labelledby="category-selector-label"
aria-label={t('com_ui_prompt_category_selector_aria')}
>
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (

View file

@ -67,7 +67,7 @@ const Command = ({
/>
<label
htmlFor="prompt-command"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-white px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 dark:bg-gray-850 md:block"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-surface-primary px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
>
{localize('com_ui_command_placeholder')}
</label>

View file

@ -64,7 +64,7 @@ const Description = ({
/>
<label
htmlFor="prompt-description"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-white px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 dark:bg-gray-850 md:block"
className="pointer-events-none absolute left-0 top-0.5 hidden max-w-[calc(100%-3.5rem)] origin-[0] translate-y-2 scale-100 rounded bg-surface-primary px-1 text-sm text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-3 peer-[:not(:placeholder-shown)]:scale-75 md:block"
>
{localize('com_ui_description_placeholder')}
</label>

View file

@ -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<Props> = ({ name, isLoading = false, onSave }) => {
const localize = useLocalize();
const inputRef = useRef<HTMLInputElement>(null);
const wasLoadingRef = useRef(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(name);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="flex min-w-0 flex-1 items-center">
{isEditing ? (
<div className="mr-3 flex min-w-0 flex-1 items-center gap-2">
<Input
type="text"
value={newName ?? ''}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
ref={inputRef}
disabled={isLoading}
className="h-10 min-w-0 flex-1 rounded-lg border border-border-medium bg-surface-primary px-3 text-xl font-semibold text-text-primary transition-colors focus:border-border-heavy disabled:opacity-60 sm:text-2xl"
aria-label={localize('com_ui_name')}
/>
<div className="flex shrink-0 items-center gap-1">
<TooltipAnchor
description={isLoading ? localize('com_ui_loading') : localize('com_ui_save')}
side="bottom"
render={
<Button
type="button"
onClick={saveName}
variant="submit"
size="icon"
disabled={isLoading}
aria-label={isLoading ? localize('com_ui_loading') : localize('com_ui_save')}
>
{isLoading ? (
<Spinner size={16} className="text-white" />
) : (
<Check className="size-4" aria-hidden="true" />
)}
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_cancel')}
side="bottom"
render={
<Button
type="button"
onClick={handleCancel}
variant="outline"
size="icon"
disabled={isLoading}
aria-label={localize('com_ui_cancel')}
>
<X className="size-4" aria-hidden="true" />
</Button>
}
/>
</div>
</div>
) : (
<button
type="button"
onClick={handleTitleClick}
onKeyDown={handleTitleKeyDown}
className="group mr-3 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_ui_edit') + ' ' + localize('com_ui_name')}
>
<span className="block truncate text-xl font-semibold text-text-primary sm:text-2xl">
{newName}
</span>
<Pencil
className="size-4 shrink-0 text-text-tertiary opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</button>
)}
</div>
);
};
export default PromptName;

View file

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

View file

@ -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 }) => (
<div className="relative mb-1 flex flex-col md:mb-0">
<div className="relative mb-1 flex w-full flex-col sm:w-auto md:mb-0">
<Input
{...field}
id="prompt-name"
type="text"
className="peer mr-2 w-full border border-border-medium p-2 text-2xl text-text-primary"
className="peer mr-2 w-full border border-border-light p-2 text-2xl text-text-primary"
placeholder=" "
tabIndex={0}
aria-label={localize('com_ui_prompt_name')}
aria-required="true"
/>
<label
htmlFor="prompt-name"
className="pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded bg-white px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75 dark:bg-gray-850"
className="pointer-events-none absolute -top-1 left-3 origin-[0] translate-y-3 scale-100 rounded bg-surface-primary px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
>
{localize('com_ui_prompt_name')}*
</label>
@ -143,12 +145,19 @@ const CreatePromptForm = ({
</div>
</div>
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-medium py-2 pl-4 pr-1 text-base font-semibold dark:text-gray-200">
<span>{localize('com_ui_prompt_text')}*</span>
<VariablesDropdown fieldName="prompt" className="mr-2" />
</h2>
<div className="min-h-32 rounded-b-lg border border-border-medium p-4 transition-all duration-150">
<div className="flex flex-col">
<header className="flex items-center justify-between rounded-t-xl border border-border-light bg-transparent p-2">
<div className="ml-1 flex items-center gap-2">
<FileText className="size-4 text-text-secondary" aria-hidden="true" />
<h2 className="text-sm font-semibold text-text-primary">
{localize('com_ui_prompt_text')}*
</h2>
</div>
<div className="flex shrink-0 items-center gap-2">
<VariablesDropdown fieldName="prompt" />
</div>
</header>
<div className="min-h-32 rounded-b-xl border border-t-0 border-border-light p-3 sm:p-4">
<Controller
name="prompt"
control={control}
@ -157,15 +166,19 @@ const CreatePromptForm = ({
<div>
<TextareaAutosize
{...field}
className="w-full rounded border border-border-medium px-2 py-1 focus:outline-none dark:bg-transparent dark:text-gray-200"
minRows={6}
className="w-full resize-none overflow-y-auto bg-transparent font-mono text-sm leading-relaxed text-text-primary placeholder:text-text-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary sm:text-base"
minRows={4}
maxRows={16}
tabIndex={0}
placeholder={localize('com_ui_prompt_input')}
aria-label={localize('com_ui_prompt_input_field')}
aria-required="true"
/>
<div
className={`mt-1 text-sm text-red-500 ${
errors.prompt ? 'visible h-auto' : 'invisible h-0'
}`}
className={cn(
'mt-1 text-sm text-red-500',
errors.prompt ? 'visible h-auto' : 'invisible h-0',
)}
>
{errors.prompt ? errors.prompt.message : ' '}
</div>
@ -183,9 +196,18 @@ const CreatePromptForm = ({
<div className="mt-4 flex justify-end">
<Button
aria-label={localize('com_ui_create_prompt')}
className={cn(
'w-full sm:w-auto',
(!isDirty || isSubmitting || !isValid) && 'opacity-50',
)}
tabIndex={0}
type="submit"
disabled={!isDirty || isSubmitting || !isValid}
aria-disabled={!isDirty || isSubmitting || !isValid || undefined}
onClick={(e: React.MouseEvent) => {
if (!isDirty || isSubmitting || !isValid) {
e.preventDefault();
}
}}
>
{localize('com_ui_create_prompt')}
</Button>

View file

@ -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<React.SetStateAction<number>>;
}
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 (
<div
className="flex h-full w-full flex-col overflow-hidden bg-surface-primary"
style={{ maxHeight: 'calc(100vh - 100px)' }}
>
{canEdit && (
<div className="shrink-0 border-b border-border-light px-4 py-3">
<Button
variant="submit"
size="sm"
aria-label={localize('com_ui_make_production')}
className={cn(
'w-full gap-2 transition-all duration-200',
isProductionVersion &&
'border border-green-500/30 bg-green-50 text-green-700 hover:bg-green-100 dark:bg-green-950/30 dark:text-green-400 dark:hover:bg-green-950/50',
)}
onClick={() => {
if (!selectedPrompt) {
return;
}
const { _id: promptVersionId = '', prompt } = selectedPrompt;
makeProductionMutation.mutate({
id: promptVersionId,
groupId,
productionPrompt: { prompt },
});
}}
disabled={
isLoadingGroup ||
!selectedPrompt ||
isProductionVersion ||
makeProductionMutation.isLoading ||
!canEdit
}
>
<Rocket className="size-4" aria-hidden="true" />
<span className="text-sm font-medium">
{isProductionVersion ? localize('com_ui_production') : localize('com_ui_deploy')}
</span>
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 py-3">
{isLoadingPrompts &&
Array.from({ length: 6 }).map((_, index: number) => (
<div key={index} className="my-2">
<Skeleton className="h-[72px] w-full" />
</div>
))}
{!isLoadingPrompts && prompts.length > 0 && (
<>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-text-secondary">
{localize('com_ui_versions')}
</h2>
<span className="flex size-5 items-center justify-center rounded-full bg-surface-tertiary text-xs font-medium text-text-secondary">
{prompts.length}
</span>
</div>
<PromptVersions
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
setSelectionIndex={setSelectionIndex}
/>
</>
)}
</div>
</div>
);
},
);
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 (
<div className="flex items-center gap-2">
<CategorySelector
currentCategory={groupCategory}
onValueChange={canEdit ? onCategoryChange : undefined}
/>
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{canDelete && (
<DeletePrompt
promptId={selectedPromptId}
groupId={groupId}
promptName={group?.name || ''}
disabled={isLoadingGroup}
/>
)}
</div>
);
},
);
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<number>(0);
const prevIsEditingRef = useRef(false);
const sidePanelRef = useRef<HTMLDivElement>(null);
const sidePanelTriggerRef = useRef<HTMLButtonElement>(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 <SkeletonForm />;
}
// 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 <NoPromptGroup />;
}
if (fetchedPrompt || group) {
return <PromptDetails group={fetchedPrompt || group} showActions={false} />;
}
}
if (!group || group._id == null) {
return null;
}
const groupName = group.name;
return (
<FormProvider {...methods}>
<form className="mt-4 flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}>
<h1 className="sr-only">{localize('com_ui_edit_prompt_page')}</h1>
<div className="relative w-full">
<div className="h-full w-full">
<div className="flex h-full">
<div className="flex-1 overflow-hidden px-4">
{/* Header: Title + Actions */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
{isLoadingGroup ? (
<Skeleton className="h-10 w-48 font-bold sm:text-xl md:h-12 md:text-2xl" />
) : (
<>
<div className="flex min-w-0 flex-1 items-center gap-2">
<PromptName
name={groupName}
isLoading={updateGroupMutation.isLoading}
onSave={(value) => {
if (!canEdit || !group._id) {
return;
}
updateGroupMutation.mutate({
id: group._id,
payload: { name: value },
});
}}
/>
{editorMode === PromptsEditorMode.ADVANCED && (
<Button
ref={sidePanelTriggerRef}
type="button"
variant="outline"
size="sm"
className="shrink-0 lg:hidden"
onClick={() => setShowSidePanel(true)}
aria-label={localize('com_ui_versions')}
>
<Menu className="mr-1.5 size-4" aria-hidden="true" />
<span>{localize('com_ui_versions')}</span>
</Button>
)}
</div>
<div className="hidden shrink-0 sm:block">
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
</>
)}
</div>
{/* Mobile Actions Row */}
{!isLoadingGroup && group && (
<div className="mb-4 sm:hidden">
<HeaderActions
group={group}
canEdit={canEdit}
canDelete={canDelete}
selectedPromptId={selectedPromptId}
onCategoryChange={handleCategoryChange}
/>
</div>
)}
{/* Main Editor Content */}
{isLoadingPrompts ? (
<Skeleton className="h-96" aria-live="polite" />
) : (
<div className="mb-2 flex h-full flex-col gap-4">
<PromptEditor
name="prompt"
isEditing={isEditing}
setIsEditing={(value) => canEdit && setIsEditing(value)}
/>
<PromptVariables promptText={promptText} />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={canEdit ? handleUpdateOneliner : undefined}
disabled={!canEdit}
/>
<Command
initialValue={group.command ?? ''}
onValueChange={canEdit ? handleUpdateCommand : undefined}
disabled={!canEdit}
/>
</div>
)}
</div>
{/* Versions Sidebar - Advanced Mode Only */}
{editorMode === PromptsEditorMode.ADVANCED && (
<div className="hidden w-72 shrink-0 border-l border-border-light lg:block xl:w-80">
<VersionsPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
)}
</div>
</div>
{/* Mobile Overlay */}
{showSidePanel && (
<button
type="button"
className="absolute inset-0 z-40 cursor-default bg-black/20"
style={{ transition: 'opacity 0.3s ease-in-out' }}
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close_menu')}
/>
)}
{/* Mobile Versions Panel */}
<div
ref={sidePanelRef}
className="absolute inset-y-0 right-0 z-50 lg:hidden"
style={{
width: sidePanelWidth,
transform: `translateX(${showSidePanel ? '0' : '100%'})`,
transition: 'transform 0.3s ease-in-out',
willChange: 'transform',
}}
role="dialog"
aria-modal="true"
aria-label={localize('com_ui_versions')}
>
<div className="h-full bg-surface-primary shadow-xl">
<div className="flex items-center justify-between border-b border-border-light px-4 py-3">
<h2 className="text-lg font-semibold text-text-primary">
{localize('com_ui_versions')}
</h2>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSidePanel(false)}
aria-label={localize('com_ui_close')}
>
<span className="sr-only">{localize('com_ui_close')}</span>
&times;
</Button>
</div>
<VersionsPanel
group={group}
prompts={prompts}
selectionIndex={selectionIndex}
selectedPrompt={selectedPrompt}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
</div>
</div>
</form>
</FormProvider>
);
};
export default PromptForm;

View file

@ -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<string>('');
const [labels, setLabels] = useState<string[]>([]);
const updatePromptLabelsMutation = useUpdatePromptLabels();
@ -13,7 +15,7 @@ const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
setLabelInput(e.target.value);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && labelInput.trim()) {
const newLabels = [...labels, labelInput.trim()];
setLabels(newLabels);
@ -30,22 +32,25 @@ const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
<Input
type="text"
className="mb-4"
placeholder="+ Add Labels"
// defaultValue={selectedPrompt?.labels.join(', ')}
placeholder={`+ ${localize('com_ui_add_labels')}`}
value={labelInput}
onChange={handleInputChange}
onKeyPress={handleKeyPress}
onKeyDown={handleKeyDown}
aria-label={localize('com_ui_add_labels')}
/>
<h3 className="rounded-t-lg border border-gray-300 px-4 text-base font-semibold">Labels</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-gray-300 p-4">
<h3 className="rounded-t-lg border border-border-light px-4 text-base font-semibold text-text-primary">
{localize('com_ui_labels')}
</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-light p-4">
{labels.length ? (
labels.map((label, index) => (
<label
<span
className="mb-1 mr-1 flex items-center gap-x-2 rounded-full border px-2"
key={index}
>
{label}
<Cross1Icon
<button
type="button"
onClick={() => {
const newLabels = labels.filter((l) => l !== label);
setLabels(newLabels);
@ -55,15 +60,18 @@ const PromptForm = ({ selectedPrompt }: { selectedPrompt?: TPrompt }) => {
});
}}
className="cursor-pointer"
/>
</label>
aria-label={`${localize('com_ui_delete')} ${label}`}
>
<Cross1Icon aria-hidden="true" />
</button>
</span>
))
) : (
<label className="rounded-full border px-2">No Labels</label>
<span className="rounded-full border px-2">{localize('com_ui_no_labels')}</span>
)}
</div>
</>
);
};
export default PromptForm;
export default PromptLabelsForm;

View file

@ -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<FormValues>({
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}
/>
);
}

View file

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

View file

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

View file

@ -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<HTMLButtonElement>(null);
const closePanelRef = useRef<HTMLButtonElement>(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 (
<PromptGroupsProvider>
<div className="flex h-screen w-full flex-col bg-surface-primary p-0 lg:p-2">
<DashBreadcrumb
showToggle={isSmallerScreen && isDetailView}
onToggle={togglePanel}
openPanelRef={openPanelRef}
/>
{isSmallerScreen && isDetailView ? (
<div className="mr-2 mt-2 flex h-10 items-center justify-between">
<button
ref={openPanelRef}
type="button"
onClick={togglePanel}
className="ml-2 flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-surface-primary text-text-primary transition-all hover:bg-surface-hover"
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="prompts-panel"
>
<Sidebar className="h-4 w-4" />
</button>
<div className="flex items-center justify-center gap-2">
{isPromptsPath && <AdvancedSwitch />}
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
</div>
</div>
) : (
<DashBreadcrumb />
)}
<div className="flex w-full flex-grow flex-row overflow-hidden">
{isSmallerScreen && panelVisible && isDetailView && (
<div
@ -92,7 +118,7 @@ export default function PromptsView() {
closePanelRef={closePanelRef}
onClose={isSmallerScreen && isDetailView ? togglePanel : undefined}
>
<div className="mt-1 flex flex-row items-center justify-between px-2 md:px-2">
<div className="mt-1 flex flex-row items-center justify-between px-2">
<FilterPrompts dropdownClassName="z-[100]" />
</div>
</GroupSidePanel>
@ -101,7 +127,7 @@ export default function PromptsView() {
<div
className={cn(
'scrollbar-gutter-stable w-full overflow-y-auto lg:w-3/4 xl:w-3/4',
'scrollbar-gutter-stable min-w-0 flex-1 overflow-y-auto',
isDetailView ? 'block' : 'hidden md:block',
)}
>

View file

@ -0,0 +1 @@
export { default as PromptsView } from './PromptsView';

View file

@ -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<HTMLButtonElement | null>(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 (
<>
<div className="mb-2 rounded-xl border border-border-light bg-transparent px-1 hover:bg-surface-secondary">
<ListCard
name={group.name}
category={group.category ?? ''}
onClick={onCardClick}
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}
icon={
isSharedPrompt || groupIsGlobal ? (
<>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 cursor-default items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<User className="icon-md text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{groupIsGlobal && (
<EarthIcon
className="icon-md shrink-0 text-green-400"
aria-label={localize('com_ui_sr_global_prompt')}
/>
)}
</>
) : undefined
}
>
<div className="flex items-center gap-1">
<TooltipAnchor
description={localize('com_ui_preview')}
side="top"
render={
<Button
ref={previewButtonRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_preview')}
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
>
<Eye className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
{canEdit && (
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_edit')}
onClick={(e) => {
e.stopPropagation();
navigate(`/d/prompts/${group._id}`);
}}
>
<Pencil className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
)}
</div>
</ListCard>
</div>
<PreviewPrompt
group={group}
open={isPreviewDialogOpen}
onOpenChange={setPreviewDialogOpen}
onCloseAutoFocus={() => {
requestAnimationFrame(() => {
previewButtonRef.current?.focus({ preventScroll: true });
});
}}
/>
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={group}
/>
</>
);
}
export default memo(ChatGroupItem);

View file

@ -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 (
<article
className={cn(
'group/card relative flex w-full items-center overflow-hidden rounded-lg border border-border-light bg-transparent text-left hover:bg-surface-secondary',
params.promptId === group._id && 'bg-surface-hover',
)}
>
<div className="flex w-0 min-w-0 flex-1 items-center gap-2 overflow-hidden p-4">
<CategoryIcon
category={group.category ?? ''}
className="icon-lg shrink-0"
aria-hidden="true"
/>
<a
href={`/d/prompts/${group._id}`}
onClick={(e) => {
e.preventDefault();
handleContainerClick();
}}
className="min-w-0 flex-1 truncate text-base font-semibold text-text-primary after:absolute after:inset-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary focus-visible:ring-offset-2"
title={group.name}
aria-label={ariaLabel}
>
{group.name}
</a>
{isSharedPrompt && (
<TooltipAnchor
description={localize('com_ui_by_author', { 0: group.authorName })}
side="top"
render={
<span
tabIndex={0}
role="img"
aria-label={localize('com_ui_by_author', { 0: group.authorName })}
className="flex shrink-0 cursor-default items-center rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<User className="icon-md text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{isGlobalGroup && (
<EarthIcon
className="icon-md shrink-0 text-green-400"
aria-label={localize('com_ui_global_group')}
/>
)}
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1 pr-2">
{canEdit && (
<OGDialog open={renameOpen} onOpenChange={setRenameOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_rename')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
>
<Pencil className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_rename_prompt')}
className="w-11/12 max-w-md"
main={
<Input
value={nameInputValue}
onChange={(e) => setNameInputValue(e.target.value)}
className="w-full"
aria-label={localize('com_ui_rename_prompt_name', { name: group.name })}
/>
}
selection={
<Button onClick={handleSaveRename} variant="submit">
{isSaving ? <Spinner /> : localize('com_ui_save')}
</Button>
}
/>
</OGDialog>
)}
{canDelete && (
<OGDialog>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
side="top"
render={
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_delete_prompt_name', { name: group.name })}
>
<Trash2 className="size-4 text-text-primary" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-md"
main={<Label>{localize('com_ui_prompt_delete_confirm', { 0: group.name })}</Label>}
selection={
<Button onClick={handleDelete} variant="destructive">
{isDeleting ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
)}
</div>
</article>
);
}
export default memo(DashGroupItemComponent);

View file

@ -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 (
<div className="flex h-full flex-col">
<section className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading && !isChatRoute && (
<div className="space-y-2 px-2">
{Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton key={index} className="flex h-14 w-full rounded-lg border-0 p-4" />
))}
</div>
)}
{!isLoading && groups.length === 0 && (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center',
isChatRoute ? 'my-2' : 'mx-2 my-4',
)}
>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<FileText className="size-5 text-text-secondary" aria-hidden="true" />
</div>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_prompts_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">
{localize('com_ui_add_first_prompt')}
</p>
</div>
)}
{isChatRoute ? (
groups.map((group) => <ChatGroupItem key={group._id} group={group} />)
) : (
<div className="space-y-2 px-0 md:px-2">
{groups.map((group) => (
<DashGroupItem key={group._id} group={group} />
))}
</div>
)}
</div>
</section>
</div>
);
}

View file

@ -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 (
<div className="relative flex w-full cursor-pointer flex-col gap-2 rounded-xl px-3 pb-4 pt-3 text-start align-top text-[15px]">
{onClick && (
<button
type="button"
className="absolute inset-0 z-0 rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
onClick={onClick}
aria-label={ariaLabel}
aria-describedby={snippetId}
/>
)}
<div className="flex w-full justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-row items-center gap-2 overflow-hidden">
<CategoryIcon category={category} className="icon-md shrink-0" aria-hidden="true" />
<Label
id={titleId}
className="min-w-0 select-none truncate text-sm font-semibold text-text-primary"
title={name}
>
{name}
</Label>
{icon}
</div>
<div className="relative z-10">{children}</div>
</div>
<div
id={snippetId}
className="ellipsis max-w-full select-none text-balance pt-1 text-sm text-text-secondary"
>
{snippet}
</div>
</div>
);
}

View file

@ -8,8 +8,8 @@ export default function NoPromptGroup() {
return (
<div className="relative min-h-full w-full px-4">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center font-bold dark:text-gray-200">
<h1 className="text-lg font-bold dark:text-gray-200 md:text-2xl">
<div className="text-center font-bold text-text-primary">
<h1 className="text-lg font-bold text-text-primary md:text-2xl">
{localize('com_ui_prompt_preview_not_shared')}
</h1>
<Button

View file

@ -0,0 +1,5 @@
export { default as List } from './List';
export { default as ListCard } from './ListCard';
export { default as DashGroupItem } from './DashGroupItem';
export { default as ChatGroupItem } from './ChatGroupItem';
export { default as NoPromptGroup } from './NoPromptGroup';

View file

@ -1,10 +1,11 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useRecoilState } from 'recoil';
import { ListFilter, User, Share2 } from 'lucide-react';
import { SystemCategories } from 'librechat-data-provider';
import { Dropdown, FilterInput } from '@librechat/client';
import { SystemCategories } from 'librechat-data-provider';
import type { Option } from '~/common';
import { useLocalize, useCategories, useDebounce } from '~/hooks';
import CreatePromptButton from '../buttons/CreatePromptButton';
import { usePromptGroupsContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
@ -88,11 +89,13 @@ export default function FilterPrompts({
if (!debouncedSearchTerm.trim()) {
return '';
}
return resultCount === 1 ? `${resultCount} result found` : `${resultCount} results found`;
}, [debouncedSearchTerm, resultCount]);
return resultCount === 1
? localize('com_ui_search_result_count', { count: resultCount })
: localize('com_ui_search_results_count', { count: resultCount });
}, [debouncedSearchTerm, resultCount, localize]);
return (
<div role="search" className={cn('flex w-full gap-2 text-text-primary', className)}>
<div role="search" className={cn('flex items-center gap-2', className)}>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{searchResultsAnnouncement}
</div>
@ -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={<ListFilter className="h-4 w-4" />}
label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')}
@ -113,6 +116,7 @@ export default function FilterPrompts({
onChange={handleSearchChange}
containerClassName="flex-1"
/>
<CreatePromptButton />
</div>
);
}

View file

@ -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 (
<div
id="prompts-panel"
className={cn(
'flex h-full w-full flex-col md:mr-2 md:w-auto md:min-w-72 lg:w-1/4 xl:w-1/4',
className,
)}
className={cn('flex h-full w-full flex-col md:mr-2 md:w-[450px] md:shrink-0', className)}
>
{onClose && (
<div className="flex items-center justify-between px-2 py-[2px] md:py-2">
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm">
<a
href="/"
onClick={chatLinkHandler}
className="flex items-center gap-1 text-text-secondary hover:text-text-primary"
>
<ArrowLeft className="icon-xs" aria-hidden="true" />
<span>{localize('com_ui_chat')}</span>
</a>
<span className="text-text-tertiary" aria-hidden="true">
/
</span>
<a
href="/d/prompts"
onClick={promptsLinkHandler}
className="text-text-secondary hover:text-text-primary"
>
{localize('com_ui_prompts')}
</a>
</nav>
<TooltipAnchor
description={localize('com_nav_close_sidebar')}
render={
@ -55,7 +92,7 @@ export default function GroupSidePanel({
/>
</div>
)}
<div className="flex flex-1 flex-col gap-2 overflow-visible">
<div className="flex min-w-0 flex-1 flex-col gap-2 overflow-hidden">
{children}
<div className={cn('relative flex h-full flex-col', isChatRoute ? '' : 'px-2 md:px-0')}>
<List

View file

@ -27,7 +27,7 @@ function PanelNavigation({
{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
{children}
</div>
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
<nav className="flex items-center gap-2" aria-label={localize('com_ui_pagination')}>
<Button
variant="outline"
size="sm"
@ -46,7 +46,7 @@ function PanelNavigation({
>
{localize('com_ui_next')}
</Button>
</div>
</nav>
</div>
);
}

View file

@ -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 (
<div className="flex h-auto w-full flex-col px-3 pb-3">
<PromptSidePanel
className="h-auto space-y-2 md:mr-0 md:min-w-0 lg:w-full xl:w-full"
{...groupsNav}
>
<FilterPrompts />
<div className="flex w-full items-center justify-end">
<AutoSendPrompt />
</div>
</PromptSidePanel>
</div>
);
}

View file

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

View file

@ -66,5 +66,5 @@ export default function CategoryIcon({
if (!IconComponent) {
return null;
}
return <IconComponent className={cn(colorClass, className)} aria-hidden="true" />;
return <IconComponent className={cn('size-4', colorClass, className)} aria-hidden="true" />;
}

View file

@ -3,7 +3,7 @@ import { Skeleton } from '@librechat/client';
export default function SkeletonForm() {
return (
<div>
<div className="flex flex-col items-center justify-between px-4 dark:text-gray-200 sm:flex-row">
<div className="flex flex-col items-center justify-between px-4 text-text-primary sm:flex-row">
<Skeleton className="mb-1 flex h-10 w-32 flex-row items-center font-bold sm:text-xl md:mb-0 md:h-12 md:text-2xl" />
</div>
<div className="flex h-full w-full flex-col md:flex-row">

View file

@ -0,0 +1,3 @@
export { default as CategoryIcon } from './CategoryIcon';
export { default as SkeletonForm } from './SkeletonForm';
export { specialVariableIcons, getSpecialVariableIcon } from './specialVariables';

View file

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

View file

@ -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 && (
<div className="ml-auto flex items-center gap-2 text-xs">
<span className="text-text-secondary">{localize('com_ui_use_memory')}</span>
<Switch
<Button
size="sm"
variant="outline"
className={`ml-auto ${referenceSavedMemories ? 'bg-surface-hover hover:bg-surface-hover' : ''}`}
onClick={() => handleMemoryToggle(!referenceSavedMemories)}
aria-label={localize('com_ui_use_memory')}
aria-pressed={referenceSavedMemories}
disabled={updateMemoryPreferencesMutation.isLoading}
>
<Checkbox
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
aria-label={localize('com_ui_use_memory')}
disabled={updateMemoryPreferencesMutation.isLoading}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none mr-2"
/>
</div>
{localize('com_ui_use_memory')}
</Button>
)}
</div>
)}

View file

@ -189,82 +189,81 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize(sectionKey) })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId={menuId}
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{permissions.map(({ permission, labelKey }) => {
const label = localize(labelKey);
const needsConfirm =
selectedRole === SystemRoles.ADMIN &&
confirmPermissions.includes(permission) &&
onPermissionConfirm;
return (
<div key={permission}>
<LabelController
control={control}
permission={permission}
label={label}
getValues={getValues}
setValue={setValue}
onConfirm={
needsConfirm
? (newValue, onChange) =>
onPermissionConfirm(permission, newValue, onChange)
: undefined
}
/>
{showAdminWarning &&
selectedRole === SystemRoles.ADMIN &&
permission === Permissions.USE && (
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="submit"
disabled={isSubmitting || isLoading}
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</div>
</form>
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId={menuId}
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{permissions.map(({ permission, labelKey }) => {
const label = localize(labelKey);
const needsConfirm =
selectedRole === SystemRoles.ADMIN &&
confirmPermissions.includes(permission) &&
onPermissionConfirm;
return (
<div key={permission}>
<LabelController
control={control}
permission={permission}
label={label}
getValues={getValues}
setValue={setValue}
onConfirm={
needsConfirm
? (newValue, onChange) =>
onPermissionConfirm(permission, newValue, onChange)
: undefined
}
/>
{showAdminWarning &&
selectedRole === SystemRoles.ADMIN &&
permission === Permissions.USE && (
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="submit"
disabled={isSubmitting || isLoading}
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</div>
</form>
</OGDialogContent>
</OGDialog>
{extraContent}

View file

@ -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<t.PromptGroupListData>(
@ -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<t.TPromptGroup>([QueryKeys.promptGroup, variables.groupId]),
),
) as t.TPromptGroup;
const groupData = queryClient.getQueryData<t.PromptGroupListData>([
const groupData = queryClient.getQueryData<t.TPromptGroup>([
QueryKeys.promptGroup,
variables.groupId,
]);
const group = groupData ? structuredClone(groupData) : undefined;
const listData = queryClient.getQueryData<t.PromptGroupListData>([
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<t.PromptGroupListData>(
[QueryKeys.promptGroups, name, category, pageSize],
newData,
);
}
if (groupData) {
queryClient.setQueryData<t.TPromptGroup>([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<t.PromptGroupListData>(
@ -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<t.PromptGroupListData>(
[QueryKeys.promptGroups, name, category, pageSize],
(old) => (old ? updateGroupFieldsInPlace(old, update) : old),
);
updateGroupInAll(queryClient, update);
},
});
};

View file

@ -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,

View file

@ -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 }[] = [

View file

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

View file

@ -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<HTMLElement | null>,
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<HTMLElement>(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]);
}

View file

@ -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",

View file

@ -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<HTMLButtonElement>;
}) {
export default function DashBreadcrumb() {
const location = useLocation();
const localize = useLocalize();
const { user } = useAuthContext();
@ -62,24 +52,6 @@ export default function DashBreadcrumb({
<div className="mr-2 mt-2 flex h-10 items-center justify-between">
<Breadcrumb className="mt-1 px-2 dark:text-gray-200">
<BreadcrumbList>
{showToggle && onToggle && (
<>
<BreadcrumbItem>
<button
ref={openPanelRef}
type="button"
onClick={onToggle}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-surface-primary text-text-primary transition-all hover:bg-surface-hover"
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="prompts-panel"
>
<Sidebar className="h-4 w-4" />
</button>
</BreadcrumbItem>
<BreadcrumbSeparator />
</>
)}
<BreadcrumbItem className="hover:dark:text-white">
<BreadcrumbLink
href="/"

View file

@ -0,0 +1,386 @@
import type { InfiniteData } from '@tanstack/react-query';
import {
addData,
findPage,
deleteData,
updateFields,
updateFieldsInPlace,
normalizeData,
getRecordByProperty,
} from '../collection';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type Item = { id: string; name: string; value?: number; updatedAt?: string };
type Page = { items: Item[]; nextCursor?: string };
function makeInfiniteData(pages: Page[]): InfiniteData<Page> {
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<Page, Item>(
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<Page, Item>(
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<Page, Item>(
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<Page, Item>(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<Page, Item>(
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<Page, Item>(
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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(
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<Page, Item>(
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<Page, Item>(
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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(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<Page, Item>(data, 'items', 10);
expect(result.pages).toHaveLength(0);
});
});

View file

@ -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> = {}): TPromptGroup {
return {
_id: 'group-1',
name: 'Default Group',
numberOfGenerations: 0,
onlyMyPrompts: false,
...overrides,
} as TPromptGroup;
}
function makeInfiniteData(
pages: Array<{ promptGroups: TPromptGroup[] }>,
): InfiniteData<PromptGroupListResponse> {
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('');
});
});

View file

@ -166,6 +166,38 @@ export const updateFields = <TCollection, TData>(
return newData;
};
export const updateFieldsInPlace = <TCollection, TData>(
data: InfiniteData<TCollection>,
updatedItem: Partial<TData>,
collectionName: string,
identifierField: keyof TData,
): InfiniteData<TCollection> => {
const identifierValue = updatedItem[identifierField];
if (identifierValue == null) {
return data;
}
const { pageIndex, index } = findPage<TCollection>(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<TData> = {
queryClient: QueryClient;
queryKey: unknown[];

View file

@ -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<PromptGroupListResponse>,
updatedGroup: Partial<TPromptGroup>,
): InfiniteData<PromptGroupListResponse> => {
return updateFieldsInPlace<PromptGroupListResponse, TPromptGroup>(
data,
updatedGroup,
InfiniteCollections.PROMPT_GROUPS,
'_id',
);
};
export const getSnippet = (promptText: string, length = 56) => {
return promptText.length > length ? `${promptText.slice(0, length - 3)}...` : promptText;
};

View file

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

View file

@ -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<string, string | number | boolean | RegExp | undefined>;
searchShared: boolean;
searchSharedOnly: boolean;
} {
const filter: Record<string, string | number | boolean | RegExp | undefined> = {
...otherFilters,
};
const filter: Record<string, string | number | boolean | RegExp | undefined> = {};
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<Types.ObjectId[]> {
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()];
}

View file

@ -74,7 +74,7 @@ const Dropdown: React.FC<DropdownProps> = ({
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}

View file

@ -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",

View file

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

View file

@ -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<t.TDeletePromptResponse> {
return request.delete(endpoints.deletePrompt(payload));
}

View file

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

View file

@ -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<Record<string, unknown>>);
} 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<string, unknown> = 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<string, unknown>;
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<IPromptGroupDocument>;
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<string, unknown>) {
try {
const PromptGroup = mongoose.models.PromptGroup as Model<IPromptGroupDocument>;
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<IPromptGroupDocument>;
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,

View file

@ -1 +1,2 @@
export { dropSupersededTenantIndexes } from './tenantIndexes';
export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes';

View file

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

View file

@ -13,6 +13,7 @@ const promptSchema: Schema<IPrompt> = new Schema(
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
prompt: {
type: String,

Some files were not shown because too many files have changed in this diff Show more