From ccd049d8ce135eff733c177c6e3a2a9c032502b5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:56:22 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=81=20refactor:=20Prompts=20UI=20(#115?= =?UTF-8?q?70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 > 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 --- .gitignore | 2 + api/server/middleware/limiters/index.js | 2 + .../middleware/limiters/promptUsageLimiter.js | 17 + api/server/routes/prompts.js | 108 ++- api/server/routes/prompts.test.js | 1 + client/src/Providers/PromptGroupsContext.tsx | 2 +- .../components/Chat/Input/PromptsCommand.tsx | 17 +- .../components/Nav/SettingsTabs/Chat/Chat.tsx | 14 + .../src/components/Prompts/AdvancedSwitch.tsx | 63 -- client/src/components/Prompts/BackToChat.tsx | 26 - .../Prompts/Groups/ChatGroupItem.tsx | 146 ---- .../Prompts/Groups/DashGroupItem.tsx | 216 ------ client/src/components/Prompts/Groups/List.tsx | 81 --- .../components/Prompts/Groups/ListCard.tsx | 59 -- client/src/components/Prompts/Markdown.tsx | 45 -- .../src/components/Prompts/PromptDetails.tsx | 83 --- .../src/components/Prompts/PromptEditor.tsx | 153 ----- client/src/components/Prompts/PromptForm.tsx | 530 --------------- client/src/components/Prompts/PromptName.tsx | 117 ---- .../components/Prompts/PromptVariables.tsx | 90 --- .../src/components/Prompts/PromptVersions.tsx | 192 ------ .../components/Prompts/PromptsAccordion.tsx | 21 - .../components/Prompts/VariablesDropdown.tsx | 76 --- .../Prompts/{ => buttons}/AdminSettings.tsx | 10 +- .../Prompts/buttons/AdvancedSwitch.tsx | 52 ++ .../{Groups => buttons}/AlwaysMakeProd.tsx | 0 .../{Groups => buttons}/AutoSendPrompt.tsx | 30 +- .../components/Prompts/buttons/BackToChat.tsx | 33 + .../Prompts/buttons/CreatePromptButton.tsx | 37 ++ .../Prompts/{ => buttons}/ManagePrompts.tsx | 3 +- .../src/components/Prompts/buttons/index.ts | 7 + .../DeletePrompt.tsx} | 55 +- .../Prompts/{ => dialogs}/PreviewPrompt.tsx | 13 +- .../Prompts/{ => dialogs}/SharePrompt.tsx | 28 +- .../{Groups => dialogs}/VariableDialog.tsx | 6 +- .../src/components/Prompts/dialogs/index.ts | 4 + .../{ => display}/EmptyPromptPreview.tsx | 0 .../Prompts/display/PromptActions.tsx | 66 ++ .../Prompts/display/PromptDetailHeader.tsx | 74 +++ .../Prompts/display/PromptDetails.tsx | 56 ++ .../Prompts/display/PromptTextCard.tsx | 110 +++ .../Prompts/display/PromptVariables.tsx | 202 ++++++ .../Prompts/display/PromptVersions.tsx | 189 ++++++ .../src/components/Prompts/display/index.ts | 7 + .../components/Prompts/editor/Markdown.tsx | 65 ++ .../Prompts/editor/PromptEditor.tsx | 154 +++++ .../Prompts/editor/VariablesDropdown.tsx | 139 ++++ client/src/components/Prompts/editor/index.ts | 3 + .../{Groups => fields}/CategorySelector.tsx | 5 +- .../Prompts/{ => fields}/Command.tsx | 2 +- .../Prompts/{ => fields}/Description.tsx | 2 +- .../components/Prompts/fields/PromptName.tsx | 163 +++++ client/src/components/Prompts/fields/index.ts | 4 + .../{Groups => forms}/CreatePromptForm.tsx | 62 +- .../components/Prompts/forms/PromptForm.tsx | 627 ++++++++++++++++++ .../PromptLabelsForm.tsx} | 34 +- .../{Groups => forms}/VariableForm.tsx | 8 +- client/src/components/Prompts/forms/index.ts | 4 + client/src/components/Prompts/index.ts | 39 +- .../Prompts/{ => layouts}/PromptsView.tsx | 54 +- .../src/components/Prompts/layouts/index.ts | 1 + .../Prompts/lists/ChatGroupItem.tsx | 153 +++++ .../Prompts/lists/DashGroupItem.tsx | 223 +++++++ client/src/components/Prompts/lists/List.tsx | 65 ++ .../src/components/Prompts/lists/ListCard.tsx | 63 ++ .../{Groups => lists}/NoPromptGroup.tsx | 4 +- client/src/components/Prompts/lists/index.ts | 5 + .../{Groups => sidebar}/FilterPrompts.tsx | 14 +- .../{Groups => sidebar}/GroupSidePanel.tsx | 57 +- .../{Groups => sidebar}/PanelNavigation.tsx | 4 +- .../Prompts/sidebar/PromptsAccordion.tsx | 21 + .../src/components/Prompts/sidebar/index.ts | 4 + .../{Groups => utils}/CategoryIcon.tsx | 2 +- .../Prompts/{ => utils}/SkeletonForm.tsx | 2 +- client/src/components/Prompts/utils/index.ts | 3 + .../Prompts/utils/specialVariables.ts | 17 + .../SidePanel/Memories/MemoryPanel.tsx | 24 +- .../src/components/ui/AdminSettingsDialog.tsx | 147 ++-- client/src/data-provider/prompts.ts | 75 ++- client/src/hooks/Nav/useSideNavLinks.ts | 4 +- client/src/hooks/Prompts/useCategories.tsx | 2 +- client/src/hooks/index.ts | 1 + client/src/hooks/useFocusTrap.ts | 68 ++ client/src/locales/en/translation.json | 45 +- client/src/routes/Layouts/DashBreadcrumb.tsx | 32 +- client/src/utils/__tests__/collection.test.ts | 386 +++++++++++ .../src/utils/__tests__/promptGroups.test.ts | 328 +++++++++ client/src/utils/collection.ts | 32 + client/src/utils/promptGroups.ts | 13 + packages/api/src/prompts/format.spec.ts | 125 ++++ packages/api/src/prompts/format.ts | 46 +- packages/client/src/components/Dropdown.tsx | 2 +- packages/data-provider/package.json | 1 + packages/data-provider/src/api-endpoints.ts | 2 + packages/data-provider/src/data-service.ts | 4 + packages/data-schemas/src/index.ts | 2 +- packages/data-schemas/src/methods/prompt.ts | 138 +++- packages/data-schemas/src/migrations/index.ts | 1 + .../src/migrations/promptGroupIndexes.ts | 65 ++ packages/data-schemas/src/schema/prompt.ts | 1 + .../data-schemas/src/schema/promptGroup.ts | 2 +- packages/data-schemas/src/utils/index.ts | 1 + packages/data-schemas/src/utils/objectId.ts | 2 + 103 files changed, 4319 insertions(+), 2276 deletions(-) create mode 100644 api/server/middleware/limiters/promptUsageLimiter.js delete mode 100644 client/src/components/Prompts/AdvancedSwitch.tsx delete mode 100644 client/src/components/Prompts/BackToChat.tsx delete mode 100644 client/src/components/Prompts/Groups/ChatGroupItem.tsx delete mode 100644 client/src/components/Prompts/Groups/DashGroupItem.tsx delete mode 100644 client/src/components/Prompts/Groups/List.tsx delete mode 100644 client/src/components/Prompts/Groups/ListCard.tsx delete mode 100644 client/src/components/Prompts/Markdown.tsx delete mode 100644 client/src/components/Prompts/PromptDetails.tsx delete mode 100644 client/src/components/Prompts/PromptEditor.tsx delete mode 100644 client/src/components/Prompts/PromptForm.tsx delete mode 100644 client/src/components/Prompts/PromptName.tsx delete mode 100644 client/src/components/Prompts/PromptVariables.tsx delete mode 100644 client/src/components/Prompts/PromptVersions.tsx delete mode 100644 client/src/components/Prompts/PromptsAccordion.tsx delete mode 100644 client/src/components/Prompts/VariablesDropdown.tsx rename client/src/components/Prompts/{ => buttons}/AdminSettings.tsx (93%) create mode 100644 client/src/components/Prompts/buttons/AdvancedSwitch.tsx rename client/src/components/Prompts/{Groups => buttons}/AlwaysMakeProd.tsx (100%) rename client/src/components/Prompts/{Groups => buttons}/AutoSendPrompt.tsx (52%) create mode 100644 client/src/components/Prompts/buttons/BackToChat.tsx create mode 100644 client/src/components/Prompts/buttons/CreatePromptButton.tsx rename client/src/components/Prompts/{ => buttons}/ManagePrompts.tsx (94%) create mode 100644 client/src/components/Prompts/buttons/index.ts rename client/src/components/Prompts/{DeleteVersion.tsx => dialogs/DeletePrompt.tsx} (63%) rename client/src/components/Prompts/{ => dialogs}/PreviewPrompt.tsx (53%) rename client/src/components/Prompts/{ => dialogs}/SharePrompt.tsx (73%) rename client/src/components/Prompts/{Groups => dialogs}/VariableDialog.tsx (89%) create mode 100644 client/src/components/Prompts/dialogs/index.ts rename client/src/components/Prompts/{ => display}/EmptyPromptPreview.tsx (100%) create mode 100644 client/src/components/Prompts/display/PromptActions.tsx create mode 100644 client/src/components/Prompts/display/PromptDetailHeader.tsx create mode 100644 client/src/components/Prompts/display/PromptDetails.tsx create mode 100644 client/src/components/Prompts/display/PromptTextCard.tsx create mode 100644 client/src/components/Prompts/display/PromptVariables.tsx create mode 100644 client/src/components/Prompts/display/PromptVersions.tsx create mode 100644 client/src/components/Prompts/display/index.ts create mode 100644 client/src/components/Prompts/editor/Markdown.tsx create mode 100644 client/src/components/Prompts/editor/PromptEditor.tsx create mode 100644 client/src/components/Prompts/editor/VariablesDropdown.tsx create mode 100644 client/src/components/Prompts/editor/index.ts rename client/src/components/Prompts/{Groups => fields}/CategorySelector.tsx (97%) rename client/src/components/Prompts/{ => fields}/Command.tsx (88%) rename client/src/components/Prompts/{ => fields}/Description.tsx (87%) create mode 100644 client/src/components/Prompts/fields/PromptName.tsx create mode 100644 client/src/components/Prompts/fields/index.ts rename client/src/components/Prompts/{Groups => forms}/CreatePromptForm.tsx (68%) create mode 100644 client/src/components/Prompts/forms/PromptForm.tsx rename client/src/components/Prompts/{PreviewLabels.tsx => forms/PromptLabelsForm.tsx} (64%) rename client/src/components/Prompts/{Groups => forms}/VariableForm.tsx (96%) create mode 100644 client/src/components/Prompts/forms/index.ts rename client/src/components/Prompts/{ => layouts}/PromptsView.tsx (64%) create mode 100644 client/src/components/Prompts/layouts/index.ts create mode 100644 client/src/components/Prompts/lists/ChatGroupItem.tsx create mode 100644 client/src/components/Prompts/lists/DashGroupItem.tsx create mode 100644 client/src/components/Prompts/lists/List.tsx create mode 100644 client/src/components/Prompts/lists/ListCard.tsx rename client/src/components/Prompts/{Groups => lists}/NoPromptGroup.tsx (84%) create mode 100644 client/src/components/Prompts/lists/index.ts rename client/src/components/Prompts/{Groups => sidebar}/FilterPrompts.tsx (87%) rename client/src/components/Prompts/{Groups => sidebar}/GroupSidePanel.tsx (52%) rename client/src/components/Prompts/{Groups => sidebar}/PanelNavigation.tsx (92%) create mode 100644 client/src/components/Prompts/sidebar/PromptsAccordion.tsx create mode 100644 client/src/components/Prompts/sidebar/index.ts rename client/src/components/Prompts/{Groups => utils}/CategoryIcon.tsx (94%) rename client/src/components/Prompts/{ => utils}/SkeletonForm.tsx (93%) create mode 100644 client/src/components/Prompts/utils/index.ts create mode 100644 client/src/components/Prompts/utils/specialVariables.ts create mode 100644 client/src/hooks/useFocusTrap.ts create mode 100644 client/src/utils/__tests__/collection.test.ts create mode 100644 client/src/utils/__tests__/promptGroups.test.ts create mode 100644 packages/api/src/prompts/format.spec.ts create mode 100644 packages/data-schemas/src/migrations/promptGroupIndexes.ts create mode 100644 packages/data-schemas/src/utils/objectId.ts diff --git a/.gitignore b/.gitignore index 980be5b8eb..ff2ae59633 100644 --- a/.gitignore +++ b/.gitignore @@ -171,5 +171,7 @@ claude-flow.config.json *.sqlite-journal *.sqlite-wal claude-flow +.playwright-mcp/* # Removed Windows wrapper files per user request hive-mind-prompt-*.txt +CLAUDE.md diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js index ab110443dc..a38188d2a6 100644 --- a/api/server/middleware/limiters/index.js +++ b/api/server/middleware/limiters/index.js @@ -8,6 +8,7 @@ const forkLimiters = require('./forkLimiters'); const registerLimiter = require('./registerLimiter'); const toolCallLimiter = require('./toolCallLimiter'); const messageLimiters = require('./messageLimiters'); +const promptUsageLimiter = require('./promptUsageLimiter'); const verifyEmailLimiter = require('./verifyEmailLimiter'); const resetPasswordLimiter = require('./resetPasswordLimiter'); @@ -16,6 +17,7 @@ module.exports = { ...importLimiters, ...messageLimiters, ...forkLimiters, + ...promptUsageLimiter, loginLimiter, registerLimiter, toolCallLimiter, diff --git a/api/server/middleware/limiters/promptUsageLimiter.js b/api/server/middleware/limiters/promptUsageLimiter.js new file mode 100644 index 0000000000..38bdeed636 --- /dev/null +++ b/api/server/middleware/limiters/promptUsageLimiter.js @@ -0,0 +1,17 @@ +const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); + +const PROMPT_USAGE_WINDOW_MS = 60 * 1000; // 1 minute +const PROMPT_USAGE_MAX = 30; // 30 usage increments per user per minute + +const promptUsageLimiter = rateLimit({ + windowMs: PROMPT_USAGE_WINDOW_MS, + max: PROMPT_USAGE_MAX, + handler: (_req, res) => { + res.status(429).json({ message: 'Too many prompt usage requests. Try again later' }); + }, + keyGenerator: (req) => req.user?.id, + store: limiterCache('prompt_usage_limiter'), +}); + +module.exports = { promptUsageLimiter }; diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 60165d367b..5fcf51ba73 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -1,5 +1,6 @@ const express = require('express'); -const { logger } = require('@librechat/data-schemas'); +const { ObjectId } = require('mongodb'); +const { logger, isValidObjectIdString } = require('@librechat/data-schemas'); const { generateCheckAccess, markPublicPromptGroups, @@ -20,6 +21,8 @@ const { const { SystemCapabilities } = require('@librechat/data-schemas'); const { getListPromptGroupsByAccess, + getOwnedPromptGroupIds, + incrementPromptGroupUsage, makePromptProduction, updatePromptGroup, deletePromptGroup, @@ -34,6 +37,7 @@ const { const { canAccessPromptGroupResource, canAccessPromptViaGroup, + promptUsageLimiter, requireJwtAuth, } = require('~/server/middleware'); const { @@ -60,6 +64,12 @@ const checkPromptCreate = generateCheckAccess({ router.use(requireJwtAuth); router.use(checkPromptAccess); +const checkGlobalPromptShare = generateCheckAccess({ + permissionType: PermissionTypes.PROMPTS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); + /** * Route to get single prompt group by its ID * GET /groups/:groupId @@ -94,11 +104,10 @@ router.get( router.get('/all', async (req, res) => { try { const userId = req.user.id; - const { name, category, ...otherFilters } = req.query; + const { name, category } = req.query; const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ name, category, - ...otherFilters, }); let accessibleIds = await findAccessibleResources({ @@ -108,16 +117,20 @@ router.get('/all', async (req, res) => { requiredPermissions: PermissionBits.VIEW, }); - const publiclyAccessibleIds = await findPubliclyAccessibleResources({ - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }); + const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + findPubliclyAccessibleResources({ + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: PermissionBits.VIEW, + }), + getOwnedPromptGroupIds(userId), + ]); const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ accessibleIds, searchShared, searchSharedOnly, publicPromptGroupIds: publiclyAccessibleIds, + ownedPromptGroupIds, }); const result = await getListPromptGroupsByAccess({ @@ -149,12 +162,11 @@ router.get('/all', async (req, res) => { router.get('/groups', async (req, res) => { try { const userId = req.user.id; - const { pageSize, limit, cursor, name, category, ...otherFilters } = req.query; + const { pageSize, limit, cursor, name, category } = req.query; const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ name, category, - ...otherFilters, }); let actualLimit = limit; @@ -178,16 +190,20 @@ router.get('/groups', async (req, res) => { requiredPermissions: PermissionBits.VIEW, }); - const publiclyAccessibleIds = await findPubliclyAccessibleResources({ - resourceType: ResourceType.PROMPTGROUP, - requiredPermissions: PermissionBits.VIEW, - }); + const [publiclyAccessibleIds, ownedPromptGroupIds] = await Promise.all([ + findPubliclyAccessibleResources({ + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: PermissionBits.VIEW, + }), + getOwnedPromptGroupIds(userId), + ]); const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ accessibleIds, searchShared, searchSharedOnly, publicPromptGroupIds: publiclyAccessibleIds, + ownedPromptGroupIds, }); // Cursor-based pagination only @@ -291,6 +307,16 @@ const addPromptToGroup = async (req, res) => { return res.status(400).send({ error: 'Prompt is required' }); } + if (typeof prompt.prompt !== 'string' || !prompt.prompt.trim()) { + return res + .status(400) + .send({ error: 'Prompt text is required and must be a non-empty string' }); + } + + if (prompt.type !== 'text' && prompt.type !== 'chat') { + return res.status(400).send({ error: 'Prompt type must be "text" or "chat"' }); + } + // Ensure the prompt is associated with the correct group prompt.groupId = groupId; @@ -321,6 +347,37 @@ router.post( addPromptToGroup, ); +/** + * Records a prompt group usage (increments numberOfGenerations) + * POST /groups/:groupId/use + */ +router.post( + '/groups/:groupId/use', + promptUsageLimiter, + canAccessPromptGroupResource({ + requiredPermission: PermissionBits.VIEW, + }), + async (req, res) => { + try { + const { groupId } = req.params; + if (!isValidObjectIdString(groupId)) { + return res.status(400).send({ error: 'Invalid groupId' }); + } + const result = await incrementPromptGroupUsage(groupId); + res.status(200).send(result); + } catch (error) { + logger.error('[recordPromptUsage]', error); + if (error.message === 'Invalid groupId') { + return res.status(400).send({ error: 'Invalid groupId' }); + } + if (error.message === 'Prompt group not found') { + return res.status(404).send({ error: 'Prompt group not found' }); + } + res.status(500).send({ error: 'Error recording prompt usage' }); + } + }, +); + /** * Updates a prompt group * @param {object} req @@ -332,18 +389,8 @@ router.post( const patchPromptGroup = async (req, res) => { try { const { groupId } = req.params; - const author = req.user.id; - const filter = { _id: groupId, author }; - let canManagePrompts = false; - try { - canManagePrompts = await hasCapability(req.user, SystemCapabilities.MANAGE_PROMPTS); - } catch (err) { - logger.warn(`[patchPromptGroup] capability check failed, denying bypass: ${err.message}`); - } - if (canManagePrompts) { - logger.debug(`[patchPromptGroup] MANAGE_PROMPTS bypass for user ${req.user.id}`); - delete filter.author; - } + // Don't pass author - permissions are now checked by middleware + const filter = { _id: groupId }; const validationResult = safeValidatePromptGroupUpdate(req.body); if (!validationResult.success) { @@ -363,7 +410,7 @@ const patchPromptGroup = async (req, res) => { router.patch( '/groups/:groupId', - checkPromptCreate, + checkGlobalPromptShare, canAccessPromptGroupResource({ requiredPermission: PermissionBits.EDIT, }), @@ -409,6 +456,10 @@ router.get('/', async (req, res) => { // If requesting prompts for a specific group, check permissions if (groupId) { + if (!isValidObjectIdString(groupId)) { + return res.status(400).send({ error: 'Invalid groupId' }); + } + const permissions = await getEffectivePermissions({ userId: req.user.id, role: req.user.role, @@ -423,7 +474,7 @@ router.get('/', async (req, res) => { } // If user has access, fetch all prompts in the group (not just their own) - const prompts = await getPrompts({ groupId }); + const prompts = await getPrompts({ groupId: new ObjectId(groupId) }); return res.status(200).send(prompts); } @@ -460,6 +511,9 @@ const deletePromptController = async (req, res) => { try { const { promptId } = req.params; const { groupId } = req.query; + if (!groupId || !isValidObjectIdString(groupId)) { + return res.status(400).send({ error: 'Invalid or missing groupId' }); + } const query = { promptId, groupId }; const result = await deletePrompt(query); res.status(200).send(result); diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index a3b868f022..c979023ffc 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -36,6 +36,7 @@ jest.mock('~/models', () => { jest.mock('~/server/middleware', () => ({ requireJwtAuth: (req, res, next) => next(), + promptUsageLimiter: (req, res, next) => next(), canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup, canAccessPromptGroupResource: jest.requireActual('~/server/middleware').canAccessPromptGroupResource, diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx index 7c9dbe8258..3df373b165 100644 --- a/client/src/Providers/PromptGroupsContext.tsx +++ b/client/src/Providers/PromptGroupsContext.tsx @@ -2,9 +2,9 @@ import React, { createContext, useContext, ReactNode, useMemo } from 'react'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; import type { PromptOption } from '~/common'; -import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; import { usePromptGroupsNav, useHasAccess } from '~/hooks'; import { useGetAllPromptGroups } from '~/data-provider'; +import { CategoryIcon } from '~/components/Prompts'; import { mapPromptGroups } from '~/utils'; type AllPromptGroupsData = diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index 1740ed43a2..f05a46f6ac 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -4,8 +4,9 @@ import { Spinner, useCombobox } from '@librechat/client'; import { useSetRecoilState, useRecoilValue } from 'recoil'; import type { TPromptGroup } from 'librechat-data-provider'; import type { PromptOption } from '~/common'; -import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; import { removeCharIfLast, detectVariables } from '~/utils'; +import { useRecordPromptUsage } from '~/data-provider'; +import { VariableDialog } from '~/components/Prompts'; import { usePromptGroupsContext } from '~/Providers'; import MentionItem from './MentionItem'; import { useLocalize } from '~/hooks'; @@ -60,6 +61,7 @@ function PromptsCommand({ submitPrompt: (textPrompt: string) => void; }) { const localize = useLocalize(); + const { mutate: recordUsage } = useRecordPromptUsage(); const { allPromptGroups, hasAccess } = usePromptGroupsContext(); const { data, isLoading } = allPromptGroups; @@ -107,9 +109,20 @@ function PromptsCommand({ return; } else { submitPrompt(group.productionPrompt?.prompt ?? ''); + if (group._id) { + recordUsage(group._id); + } } }, - [setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt], + [ + setSearchValue, + setOpen, + setShowPromptsPopover, + textAreaRef, + promptsMap, + submitPrompt, + recordUsage, + ], ); useEffect(() => { diff --git a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx index 5cbbd73619..e68d008f3d 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx @@ -7,6 +7,20 @@ import ToggleSwitch from '../ToggleSwitch'; import store from '~/store'; const toggleSwitchConfigs = [ + { + stateAtom: store.alwaysMakeProd, + localizationKey: 'com_nav_always_make_prod' as const, + switchId: 'alwaysMakeProd', + hoverCardText: undefined, + key: 'alwaysMakeProd', + }, + { + stateAtom: store.autoSendPrompts, + localizationKey: 'com_nav_auto_send_prompts' as const, + switchId: 'autoSendPrompts', + hoverCardText: 'com_nav_auto_send_prompts_desc' as const, + key: 'autoSendPrompts', + }, { stateAtom: store.enterToSend, localizationKey: 'com_nav_enter_to_send' as const, diff --git a/client/src/components/Prompts/AdvancedSwitch.tsx b/client/src/components/Prompts/AdvancedSwitch.tsx deleted file mode 100644 index b050f6b343..0000000000 --- a/client/src/components/Prompts/AdvancedSwitch.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useRecoilState, useSetRecoilState } from 'recoil'; -import { PromptsEditorMode } from '~/common'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -const { promptsEditorMode, alwaysMakeProd } = store; - -const AdvancedSwitch = () => { - const localize = useLocalize(); - const [mode, setMode] = useRecoilState(promptsEditorMode); - const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd); - - return ( -
-
-
- - {/* Simple Mode Button */} - - - {/* Advanced Mode Button */} - -
-
- ); -}; - -export default AdvancedSwitch; diff --git a/client/src/components/Prompts/BackToChat.tsx b/client/src/components/Prompts/BackToChat.tsx deleted file mode 100644 index c8e28ba2fe..0000000000 --- a/client/src/components/Prompts/BackToChat.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ArrowLeft } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { buttonVariants } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import { cn } from '~/utils'; - -export default function BackToChat({ className }: { className?: string }) { - const navigate = useNavigate(); - const localize = useLocalize(); - const clickHandler = (event: React.MouseEvent) => { - if (event.button === 0 && !(event.ctrlKey || event.metaKey)) { - event.preventDefault(); - navigate('/c/new'); - } - }; - return ( - - - ); -} diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx deleted file mode 100644 index 9c4f149e57..0000000000 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useState, useMemo, memo, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { PermissionBits, ResourceType } from 'librechat-data-provider'; -import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuGroup, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@librechat/client'; -import type { TPromptGroup } from 'librechat-data-provider'; -import { useLocalize, useSubmitMessage, useResourcePermissions } from '~/hooks'; -import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; -import PreviewPrompt from '~/components/Prompts/PreviewPrompt'; -import ListCard from '~/components/Prompts/Groups/ListCard'; -import { detectVariables } from '~/utils'; - -function ChatGroupItem({ group }: { group: TPromptGroup }) { - const localize = useLocalize(); - const { submitPrompt } = useSubmitMessage(); - const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false); - const [isVariableDialogOpen, setVariableDialogOpen] = useState(false); - - const groupIsGlobal = useMemo(() => group.isPublic === true, [group.isPublic]); - - // Check permissions for the promptGroup - const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || ''); - const canEdit = hasPermission(PermissionBits.EDIT); - - const triggerButtonRef = useRef(null); - - const onCardClick: React.MouseEventHandler = () => { - const text = group.productionPrompt?.prompt; - if (!text?.trim()) { - return; - } - - if (detectVariables(text)) { - setVariableDialogOpen(true); - return; - } - - submitPrompt(text); - }; - - return ( - <> -
- 0 - ? group.oneliner - : (group.productionPrompt?.prompt ?? '') - } - > - {groupIsGlobal === true && ( -
- -
- )} -
- - - - - - { - e.stopPropagation(); - 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" - > - - {canEdit && ( - - - - - - )} - - -
-
- { - requestAnimationFrame(() => { - triggerButtonRef.current?.focus({ preventScroll: true }); - }); - }} - /> - setVariableDialogOpen(false)} - group={group} - /> - - ); -} - -export default memo(ChatGroupItem); diff --git a/client/src/components/Prompts/Groups/DashGroupItem.tsx b/client/src/components/Prompts/Groups/DashGroupItem.tsx deleted file mode 100644 index ee8c9acf38..0000000000 --- a/client/src/components/Prompts/Groups/DashGroupItem.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'react'; -import { Trans } from 'react-i18next'; -import { EarthIcon, Pen } from 'lucide-react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { PermissionBits, ResourceType, type TPromptGroup } from 'librechat-data-provider'; -import { - Input, - Label, - OGDialog, - OGDialogTrigger, - OGDialogTemplate, - TrashIcon, -} from '@librechat/client'; -import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider'; -import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; -import { useLocalize, useResourcePermissions } from '~/hooks'; -import { useLiveAnnouncer } from '~/Providers'; -import { cn } from '~/utils'; - -interface DashGroupItemProps { - group: TPromptGroup; -} - -function DashGroupItemComponent({ group }: DashGroupItemProps) { - const params = useParams(); - const navigate = useNavigate(); - const localize = useLocalize(); - const { announcePolite } = useLiveAnnouncer(); - - const blurTimeoutRef = useRef(null); - const [nameInputValue, setNameInputValue] = useState(group.name); - - const { hasPermission } = useResourcePermissions(ResourceType.PROMPTGROUP, group._id || ''); - const canEdit = hasPermission(PermissionBits.EDIT); - const canDelete = hasPermission(PermissionBits.DELETE); - - const isPublicGroup = useMemo(() => group.isPublic === true, [group.isPublic]); - - const updateGroup = useUpdatePromptGroup({ - onMutate: () => { - if (blurTimeoutRef.current) { - clearTimeout(blurTimeoutRef.current); - } - }, - }); - - const deleteGroup = useDeletePromptGroup({ - onSuccess: (_response, variables) => { - if (variables.id === group._id) { - const announcement = localize('com_ui_prompt_deleted', { 0: group.name }); - announcePolite({ message: announcement, isStatus: true }); - navigate('/d/prompts'); - } - }, - }); - - const { isLoading } = updateGroup; - - const handleSaveRename = useCallback(() => { - console.log(group._id ?? '', { name: nameInputValue }); - updateGroup.mutate({ id: group._id ?? '', payload: { name: nameInputValue } }); - }, [group._id, nameInputValue, updateGroup]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - navigate(`/d/prompts/${group._id}`, { replace: true }); - } - }, - [group._id, navigate], - ); - - const triggerDelete = useCallback(() => { - deleteGroup.mutate({ id: group._id ?? '' }); - }, [group._id, deleteGroup]); - - const handleContainerClick = useCallback(() => { - navigate(`/d/prompts/${group._id}`, { replace: true }); - }, [group._id, navigate]); - - return ( -
- - -
- {canEdit && ( - - - - - -
- setNameInputValue(e.target.value)} - className="w-full" - aria-label={localize('com_ui_rename_prompt_name', { name: group.name })} - /> -
-
- } - selection={{ - selectHandler: handleSaveRename, - selectClasses: - 'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit', - selectText: localize('com_ui_save'), - isLoading, - }} - /> - - )} - - {canDelete && ( - - - - - -
- -
-
- } - selection={{ - selectHandler: triggerDelete, - selectClasses: - 'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> - - )} -
- - ); -} - -export default memo(DashGroupItemComponent); diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx deleted file mode 100644 index ec19a07068..0000000000 --- a/client/src/components/Prompts/Groups/List.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { FileText, Plus } from 'lucide-react'; -import { Link } from 'react-router-dom'; -import { Button, Skeleton } from '@librechat/client'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import type { TPromptGroup } from 'librechat-data-provider'; -import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem'; -import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem'; -import { useLocalize, useHasAccess } from '~/hooks'; -import { cn } from '~/utils'; - -export default function List({ - groups = [], - isChatRoute, - isLoading, -}: { - groups?: TPromptGroup[]; - isChatRoute: boolean; - isLoading: boolean; -}) { - const localize = useLocalize(); - const hasCreateAccess = useHasAccess({ - permissionType: PermissionTypes.PROMPTS, - permission: Permissions.CREATE, - }); - - return ( -
- {hasCreateAccess && ( -
- -
- )} -
-
- {isLoading && isChatRoute && ( - - )} - {isLoading && - !isChatRoute && - Array.from({ length: 10 }).map((_, index: number) => ( - - ))} - {!isLoading && groups.length === 0 && ( -
-
-
-

- {localize('com_ui_no_prompts_title')} -

-

- {localize('com_ui_add_first_prompt')} -

-
- )} - {groups.map((group) => { - if (isChatRoute) { - return ; - } - return ; - })} -
-
-
- ); -} diff --git a/client/src/components/Prompts/Groups/ListCard.tsx b/client/src/components/Prompts/Groups/ListCard.tsx deleted file mode 100644 index 0503a69aa6..0000000000 --- a/client/src/components/Prompts/Groups/ListCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { Label } from '@librechat/client'; -import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; -import { useLocalize } from '~/hooks'; - -export default function ListCard({ - category, - name, - snippet, - onClick, - children, -}: { - category: string; - name: string; - snippet: string; - onClick?: React.MouseEventHandler; - children?: React.ReactNode; -}) { - const localize = useLocalize(); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onClick?.(event as unknown as React.MouseEvent); - } - }; - - return ( -
-
-
-
-
{children}
-
-
- {snippet} -
-
- ); -} diff --git a/client/src/components/Prompts/Markdown.tsx b/client/src/components/Prompts/Markdown.tsx deleted file mode 100644 index 23353ed7ea..0000000000 --- a/client/src/components/Prompts/Markdown.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { handleDoubleClick } from '~/utils'; - -export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); -}; - -const regex = /{{(.*?)}}/g; -export const PromptVariableGfm = ({ - children, -}: { - children: React.ReactNode & React.ReactNode[]; -}) => { - const renderContent = (child: React.ReactNode) => { - if (typeof child === 'object' && child !== null) { - return child; - } - if (typeof child !== 'string') { - return child; - } - - const parts = child.split(regex); - return parts.map((part, index) => - index % 2 === 1 ? ( - - {`{{${part}}}`} - - ) : ( - part - ), - ); - }; - - return

{React.Children.map(children, (child) => renderContent(child))}

; -}; diff --git a/client/src/components/Prompts/PromptDetails.tsx b/client/src/components/Prompts/PromptDetails.tsx deleted file mode 100644 index f759079d18..0000000000 --- a/client/src/components/Prompts/PromptDetails.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useMemo } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import rehypeKatex from 'rehype-katex'; -import remarkMath from 'remark-math'; -import supersub from 'remark-supersub'; -import { Label } from '@librechat/client'; -import rehypeHighlight from 'rehype-highlight'; -import { replaceSpecialVars } from 'librechat-data-provider'; -import type { TPromptGroup } from 'librechat-data-provider'; -import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents'; -import { useLocalize, useAuthContext } from '~/hooks'; -import CategoryIcon from './Groups/CategoryIcon'; -import PromptVariables from './PromptVariables'; -import { PromptVariableGfm } from './Markdown'; -import Description from './Description'; -import Command from './Command'; - -const PromptDetails = ({ group }: { group?: TPromptGroup }) => { - const localize = useLocalize(); - const { user } = useAuthContext(); - - const mainText = useMemo(() => { - const initialText = group?.productionPrompt?.prompt ?? ''; - return replaceSpecialVars({ text: initialText, user }); - }, [group?.productionPrompt?.prompt, user]); - - if (!group) { - return null; - } - - return ( -
-
-
-
-
- {(group.category?.length ?? 0) > 0 ? ( - - ) : null} -
- -
-
-
-
-
-
-

- {localize('com_ui_prompt_text')} -

-
- - {mainText} - -
-
- - - -
-
-
- ); -}; - -export default PromptDetails; diff --git a/client/src/components/Prompts/PromptEditor.tsx b/client/src/components/Prompts/PromptEditor.tsx deleted file mode 100644 index ea0d1ef15c..0000000000 --- a/client/src/components/Prompts/PromptEditor.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useMemo, memo } from 'react'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import supersub from 'remark-supersub'; -import { useRecoilValue } from 'recoil'; -import { EditIcon } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import rehypeHighlight from 'rehype-highlight'; -import { SaveIcon, CrossIcon, TextareaAutosize } from '@librechat/client'; -import { Controller, useFormContext, useFormState } from 'react-hook-form'; -import type { PluggableList } from 'unified'; -import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents'; -import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd'; -import VariablesDropdown from './VariablesDropdown'; -import { PromptVariableGfm } from './Markdown'; -import { PromptsEditorMode } from '~/common'; -import { cn, langSubset } from '~/utils'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -const { promptsEditorMode } = store; - -type Props = { - name: string; - isEditing: boolean; - setIsEditing: React.Dispatch>; -}; - -const PromptEditor: React.FC = ({ name, isEditing, setIsEditing }) => { - const localize = useLocalize(); - const { control } = useFormContext(); - const editorMode = useRecoilValue(promptsEditorMode); - const { dirtyFields } = useFormState({ control: control }); - const { prompt } = dirtyFields as { prompt?: string }; - - const EditorIcon = useMemo(() => { - if (isEditing && prompt?.length == null) { - return CrossIcon; - } - return isEditing ? SaveIcon : EditIcon; - }, [isEditing, prompt]); - - const rehypePlugins: PluggableList = [ - [rehypeKatex], - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - subset: langSubset, - }, - ], - ]; - - return ( -
-

{localize('com_ui_control_bar')}

-
- - {localize('com_ui_prompt_text')} - -
- {editorMode === PromptsEditorMode.ADVANCED && ( - - )} - - -
-
-
!isEditing && setIsEditing(true)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - !isEditing && setIsEditing(true); - } - }} - tabIndex={0} - > - {!isEditing && ( - - )} - - isEditing ? ( - setIsEditing(false)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault(); - setIsEditing(false); - } - }} - aria-label={localize('com_ui_prompt_input')} - /> - ) : ( -
- - {field.value} - -
- ) - } - /> -
-
- ); -}; - -export default memo(PromptEditor); diff --git a/client/src/components/Prompts/PromptForm.tsx b/client/src/components/Prompts/PromptForm.tsx deleted file mode 100644 index 6f575f8577..0000000000 --- a/client/src/components/Prompts/PromptForm.tsx +++ /dev/null @@ -1,530 +0,0 @@ -import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import React from 'react'; -import debounce from 'lodash/debounce'; -import { useRecoilValue } from 'recoil'; -import { Menu, Rocket } from 'lucide-react'; -import { useParams } from 'react-router-dom'; -import { useForm, FormProvider } from 'react-hook-form'; -import { Button, Skeleton, useToastContext } from '@librechat/client'; -import { - Permissions, - ResourceType, - PermissionBits, - PermissionTypes, -} from 'librechat-data-provider'; -import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider'; -import { - useGetPrompts, - useGetPromptGroup, - useAddPromptToGroup, - useUpdatePromptGroup, - useMakePromptProduction, -} from '~/data-provider'; -import { useResourcePermissions, useHasAccess, useLocalize } from '~/hooks'; -import CategorySelector from './Groups/CategorySelector'; -import { usePromptGroupsContext } from '~/Providers'; -import NoPromptGroup from './Groups/NoPromptGroup'; -import PromptVariables from './PromptVariables'; -import { cn, findPromptGroup } from '~/utils'; -import PromptVersions from './PromptVersions'; -import { PromptsEditorMode } from '~/common'; -import DeleteVersion from './DeleteVersion'; -import PromptDetails from './PromptDetails'; -import PromptEditor from './PromptEditor'; -import SkeletonForm from './SkeletonForm'; -import Description from './Description'; -import SharePrompt from './SharePrompt'; -import PromptName from './PromptName'; -import Command from './Command'; -import store from '~/store'; - -interface RightPanelProps { - group: TPromptGroup; - prompts: TPrompt[]; - selectedPrompt: any; - selectionIndex: number; - selectedPromptId?: string; - isLoadingPrompts: boolean; - canEdit: boolean; - setSelectionIndex: React.Dispatch>; -} - -const RightPanel = React.memo( - ({ - group, - prompts, - selectedPrompt, - selectedPromptId, - isLoadingPrompts, - canEdit, - selectionIndex, - setSelectionIndex, - }: RightPanelProps) => { - const localize = useLocalize(); - const { showToast } = useToastContext(); - const editorMode = useRecoilValue(store.promptsEditorMode); - const hasShareAccess = useHasAccess({ - permissionType: PermissionTypes.PROMPTS, - permission: Permissions.SHARE, - }); - - const updateGroupMutation = useUpdatePromptGroup({ - onError: () => { - showToast({ - status: 'error', - message: localize('com_ui_prompt_update_error'), - }); - }, - }); - - const makeProductionMutation = useMakePromptProduction(); - - const groupId = group?._id || ''; - const groupName = group?.name || ''; - const groupCategory = group?.category || ''; - const isLoadingGroup = !group; - - return ( -
-
- - updateGroupMutation.mutate({ - id: groupId, - payload: { name: groupName, category: value }, - }) - : undefined - } - /> -
- {hasShareAccess && } - {editorMode === PromptsEditorMode.ADVANCED && canEdit && ( - - )} - -
-
- {editorMode === PromptsEditorMode.ADVANCED && - (isLoadingPrompts - ? Array.from({ length: 6 }).map((_, index: number) => ( -
- -
- )) - : prompts.length > 0 && ( - - ))} -
- ); - }, -); - -RightPanel.displayName = 'RightPanel'; - -const PromptForm = () => { - const params = useParams(); - const localize = useLocalize(); - const { showToast } = useToastContext(); - const { hasAccess } = usePromptGroupsContext(); - const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd); - const promptId = params.promptId || ''; - - const editorMode = useRecoilValue(store.promptsEditorMode); - const [selectionIndex, setSelectionIndex] = useState(0); - - const prevIsEditingRef = useRef(false); - const [isEditing, setIsEditing] = useState(false); - const [initialLoad, setInitialLoad] = useState(true); - const [showSidePanel, setShowSidePanel] = useState(false); - const sidePanelWidth = '320px'; - - const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId, { - enabled: hasAccess && !!promptId, - }); - const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts( - { groupId: promptId }, - { enabled: hasAccess && !!promptId }, - ); - - const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( - ResourceType.PROMPTGROUP, - group?._id || '', - ); - - const canEdit = hasPermission(PermissionBits.EDIT); - const canView = hasPermission(PermissionBits.VIEW); - - const methods = useForm({ - defaultValues: { - prompt: '', - promptName: group ? group.name : '', - category: group ? group.category : '', - }, - }); - const { handleSubmit, setValue, reset, watch } = methods; - const promptText = watch('prompt'); - - const selectedPrompt = useMemo( - () => (prompts.length > 0 ? prompts[selectionIndex] : undefined), - [prompts, selectionIndex], - ); - - const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]); - - const { groupsQuery } = usePromptGroupsContext(); - - const updateGroupMutation = useUpdatePromptGroup({ - onError: () => { - showToast({ - status: 'error', - message: localize('com_ui_prompt_update_error'), - }); - }, - }); - - const makeProductionMutation = useMakePromptProduction(); - const addPromptToGroupMutation = useAddPromptToGroup({ - onMutate: (variables) => { - reset( - { - prompt: variables.prompt.prompt, - category: group?.category || '', - }, - { keepDirtyValues: true }, - ); - }, - onSuccess(data) { - if (alwaysMakeProd && data.prompt._id != null && data.prompt._id && data.prompt.groupId) { - makeProductionMutation.mutate({ - id: data.prompt._id, - groupId: data.prompt.groupId, - productionPrompt: { prompt: data.prompt.prompt }, - }); - } - - reset({ - prompt: data.prompt.prompt, - promptName: group?.name || '', - category: group?.category || '', - }); - }, - }); - - const onSave = useCallback( - (value: string) => { - if (!canEdit) { - return; - } - if (!value) { - // TODO: show toast, cannot be empty. - return; - } - if (!selectedPrompt) { - return; - } - - const groupId = selectedPrompt.groupId || group?._id; - if (!groupId) { - console.error('No groupId available'); - return; - } - - const tempPrompt: TCreatePrompt = { - prompt: { - type: selectedPrompt.type ?? 'text', - groupId: groupId, - prompt: value, - }, - }; - - if (value === selectedPrompt.prompt) { - return; - } - - // We're adding to an existing group, so use the addPromptToGroup mutation - addPromptToGroupMutation.mutate({ ...tempPrompt, groupId }); - }, - [selectedPrompt, group, addPromptToGroupMutation, canEdit], - ); - - const handleLoadingComplete = useCallback(() => { - if (isLoadingGroup || isLoadingPrompts) { - return; - } - setInitialLoad(false); - }, [isLoadingGroup, isLoadingPrompts]); - - useEffect(() => { - if (prevIsEditingRef.current && !isEditing && canEdit) { - handleSubmit((data) => onSave(data.prompt))(); - } - prevIsEditingRef.current = isEditing; - }, [isEditing, onSave, handleSubmit, canEdit]); - - useEffect(() => { - handleLoadingComplete(); - }, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]); - - useEffect(() => { - setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false }); - setValue('category', group ? group.category : '', { shouldDirty: false }); - }, [selectedPrompt, group, setValue]); - - useEffect(() => { - const handleResize = () => { - if (window.matchMedia('(min-width: 1022px)').matches) { - setShowSidePanel(false); - } - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - const debouncedUpdateOneliner = useMemo( - () => - debounce((groupId: string, oneliner: string, mutate: any) => { - mutate({ id: groupId, payload: { oneliner } }); - }, 950), - [], - ); - - const debouncedUpdateCommand = useMemo( - () => - debounce((groupId: string, command: string, mutate: any) => { - mutate({ id: groupId, payload: { command } }); - }, 950), - [], - ); - - const handleUpdateOneliner = useCallback( - (oneliner: string) => { - if (!group || !group._id) { - return console.warn('Group not found'); - } - debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate); - }, - [group, updateGroupMutation.mutate, debouncedUpdateOneliner], - ); - - const handleUpdateCommand = useCallback( - (command: string) => { - if (!group || !group._id) { - return console.warn('Group not found'); - } - debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate); - }, - [group, updateGroupMutation.mutate, debouncedUpdateCommand], - ); - - if (initialLoad) { - return ; - } - - // Show read-only view if user doesn't have edit permission - if (!canEdit && !permissionsLoading && groupsQuery.data) { - const fetchedPrompt = findPromptGroup( - groupsQuery.data, - (group) => group._id === params.promptId, - ); - if (!fetchedPrompt && !canView) { - return ; - } - - if (fetchedPrompt || group) { - return ; - } - } - - if (!group || group._id == null) { - return null; - } - - const groupName = group.name; - - return ( - -
onSave(data.prompt))}> -

{localize('com_ui_edit_prompt_page')}

-
-
-
-
-
- {isLoadingGroup ? ( - - ) : ( - <> - { - if (!canEdit || !group._id) { - return; - } - updateGroupMutation.mutate({ - id: group._id, - payload: { name: value }, - }); - }} - /> -
- -
- {editorMode === PromptsEditorMode.SIMPLE && ( - - )} -
- - )} -
- {isLoadingPrompts ? ( - - ) : ( -
- canEdit && setIsEditing(value)} - /> - - - -
- )} -
- - {editorMode === PromptsEditorMode.ADVANCED && ( -
- -
- )} -
-
- -
- - - ); -}; - -export default PromptForm; diff --git a/client/src/components/Prompts/PromptName.tsx b/client/src/components/Prompts/PromptName.tsx deleted file mode 100644 index cfb18ec973..0000000000 --- a/client/src/components/Prompts/PromptName.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { Button, Label, Input, EditIcon, SaveIcon } from '@librechat/client'; - -type Props = { - name?: string; - onSave: (newName: string) => void; -}; - -const PromptName: React.FC = ({ name, onSave }) => { - const inputRef = useRef(null); - const blurTimeoutRef = useRef(); - const [isEditing, setIsEditing] = useState(false); - const [newName, setNewName] = useState(name); - - const handleEditClick = () => { - setIsEditing(true); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - setNewName(e.target.value); - }; - - const saveName = () => { - const savedName = newName?.trim(); - onSave(savedName || ''); - setIsEditing(false); - }; - - const handleSaveClick: React.MouseEventHandler = () => { - saveName(); - clearTimeout(blurTimeoutRef.current); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - setIsEditing(false); - setNewName(name); - } - if (e.key === 'Enter') { - saveName(); - } - }; - - useEffect(() => { - if (isEditing) { - inputRef.current?.focus(); - } - }, [isEditing]); - - useEffect(() => { - setNewName(name); - }, [name]); - - return ( -
-
- {isEditing ? ( - <> - - - - - ) : ( - <> - - - - )} -
-
- ); -}; - -export default PromptName; diff --git a/client/src/components/Prompts/PromptVariables.tsx b/client/src/components/Prompts/PromptVariables.tsx deleted file mode 100644 index fbb956e844..0000000000 --- a/client/src/components/Prompts/PromptVariables.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useMemo } from 'react'; -import { Variable } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import { Separator } from '@librechat/client'; -import { specialVariables } from 'librechat-data-provider'; -import { cn, extractUniqueVariables } from '~/utils'; -import { CodeVariableGfm } from './Markdown'; -import { useLocalize } from '~/hooks'; - -const specialVariableClasses = - 'bg-amber-100 text-yellow-800 border-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90'; - -const components: { - [nodeType: string]: React.ElementType; -} = { code: CodeVariableGfm }; - -const PromptVariables = ({ - promptText, - showInfo = true, -}: { - promptText: string; - showInfo?: boolean; -}) => { - const localize = useLocalize(); - - const variables = useMemo(() => { - return extractUniqueVariables(promptText || ''); - }, [promptText]); - - return ( -
-

-

-
- {variables.length ? ( -
- {variables.map((variable, index) => ( - - {specialVariables[variable.toLowerCase()] != null - ? variable.toLowerCase() - : variable} - - ))} -
- ) : ( -
- - {localize('com_ui_variables_info')} - -
- )} - - {showInfo && ( -
-
- - {localize('com_ui_special_variables')} - - - - {localize('com_ui_special_variables_more_info')} - - -
-
- - {localize('com_ui_dropdown_variables')} - - - - {localize('com_ui_dropdown_variables_info')} - - -
-
- )} -
-
- ); -}; - -export default PromptVariables; diff --git a/client/src/components/Prompts/PromptVersions.tsx b/client/src/components/Prompts/PromptVersions.tsx deleted file mode 100644 index d21562edcd..0000000000 --- a/client/src/components/Prompts/PromptVersions.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React from 'react'; -import { format } from 'date-fns'; -import { Layers3, Crown, Zap } from 'lucide-react'; -import { Tag, TooltipAnchor, Label } from '@librechat/client'; -import type { TPrompt, TPromptGroup } from 'librechat-data-provider'; -import { useLocalize } from '~/hooks'; -import { cn } from '~/utils'; - -const CombinedStatusIcon = ({ description }: { description: string }) => ( - - -
- } - > -); - -const VersionTags = ({ tags }: { tags: string[] }) => { - const localize = useLocalize(); - const isLatestAndProduction = tags.includes('latest') && tags.includes('production'); - - if (isLatestAndProduction) { - return ( - - - - ); - } - - return ( - - {tags.map((tag, i) => ( - { - if (tag === 'production') { - return ( -
- -
- ); - } - if (tag === 'latest') { - return ( -
- -
- ); - } - return null; - })()} - /> - } - >
- ))} -
- ); -}; - -const VersionCard = ({ - prompt, - index, - isSelected, - totalVersions, - onClick, - authorName, - tags, -}: { - prompt: TPrompt; - index: number; - isSelected: boolean; - totalVersions: number; - onClick: () => void; - authorName?: string; - tags: string[]; -}) => { - const localize = useLocalize(); - - return ( - - ); -}; - -const PromptVersions = ({ - prompts, - group, - selectionIndex, - setSelectionIndex, -}: { - prompts: TPrompt[]; - group?: TPromptGroup; - selectionIndex: number; - setSelectionIndex: React.Dispatch>; -}) => { - const localize = useLocalize(); - - return ( -
-
-

- - {localize('com_ui_versions')} -

-
- -
- {prompts.map((prompt: TPrompt, index: number) => { - const tags: string[] = []; - - if (index === 0) { - tags.push('latest'); - } - - if (prompt._id === group?.productionId) { - tags.push('production'); - } - - return ( - setSelectionIndex(index)} - authorName={group?.authorName} - tags={tags} - /> - ); - })} -
-
- ); -}; - -export default PromptVersions; diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx deleted file mode 100644 index 67ece18cc2..0000000000 --- a/client/src/components/Prompts/PromptsAccordion.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel'; -import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt'; -import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; -import { usePromptGroupsContext } from '~/Providers'; - -export default function PromptsAccordion() { - const groupsNav = usePromptGroupsContext(); - return ( -
- - -
- -
-
-
- ); -} diff --git a/client/src/components/Prompts/VariablesDropdown.tsx b/client/src/components/Prompts/VariablesDropdown.tsx deleted file mode 100644 index bfde6525c9..0000000000 --- a/client/src/components/Prompts/VariablesDropdown.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useState, useId } from 'react'; -import { PlusCircle } from 'lucide-react'; -import * as Menu from '@ariakit/react/menu'; -import { useFormContext } from 'react-hook-form'; -import { DropdownPopup } from '@librechat/client'; -import { specialVariables } from 'librechat-data-provider'; -import type { TSpecialVarLabel } from 'librechat-data-provider'; -import { useLiveAnnouncer } from '~/Providers'; -import { useLocalize } from '~/hooks'; - -interface VariableOption { - label: TSpecialVarLabel; - value: string; -} - -const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({ - label: `com_ui_special_var_${key}` as TSpecialVarLabel, - value: `{{${key}}}`, -})); - -interface VariablesDropdownProps { - fieldName?: string; - className?: string; -} - -export default function VariablesDropdown({ - fieldName = 'prompt', - className = '', -}: VariablesDropdownProps) { - const menuId = useId(); - const localize = useLocalize(); - const methods = useFormContext(); - const { setValue, getValues } = methods; - const { announcePolite } = useLiveAnnouncer(); - - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const handleAddVariable = (label: TSpecialVarLabel, value: string) => { - const currentText = getValues(fieldName) || ''; - const spacer = currentText.length > 0 ? '\n\n' : ''; - const prefix = localize(label); - setValue(fieldName, currentText + spacer + prefix + ': ' + value); - setIsMenuOpen(false); - const announcement = localize('com_ui_special_variable_added', { 0: prefix }); - announcePolite({ message: announcement, isStatus: true }); - }; - - return ( -
- - - {localize('com_ui_special_variables')} - - } - items={variableOptions.map((option) => ({ - label: localize(option.label) || option.label, - onClick: () => handleAddVariable(option.label, option.value), - }))} - menuId={menuId} - className="z-30" - /> -
- ); -} diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/buttons/AdminSettings.tsx similarity index 93% rename from client/src/components/Prompts/AdminSettings.tsx rename to client/src/components/Prompts/buttons/AdminSettings.tsx index 6d382fbb91..25e6df05a8 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/buttons/AdminSettings.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import { ShieldEllipsis } from 'lucide-react'; import { Permissions, PermissionTypes } from 'librechat-data-provider'; import { OGDialog, OGDialogTemplate, Button, useToastContext } from '@librechat/client'; -import { AdminSettingsDialog } from '~/components/ui'; -import { useUpdatePromptPermissionsMutation } from '~/data-provider'; -import { useLocalize } from '~/hooks'; import type { PermissionConfig } from '~/components/ui'; +import { useUpdatePromptPermissionsMutation } from '~/data-provider'; +import { AdminSettingsDialog } from '~/components/ui'; +import { useLocalize } from '~/hooks'; const permissions: PermissionConfig[] = [ { permission: Permissions.USE, labelKey: 'com_ui_prompts_allow_use' }, @@ -43,7 +43,8 @@ const AdminSettings = () => { ); } diff --git a/client/src/components/Prompts/buttons/BackToChat.tsx b/client/src/components/Prompts/buttons/BackToChat.tsx new file mode 100644 index 0000000000..0840e87f46 --- /dev/null +++ b/client/src/components/Prompts/buttons/BackToChat.tsx @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { ArrowLeft } from 'lucide-react'; +import { buttonVariants } from '@librechat/client'; +import { useDashboardContext } from '~/Providers'; +import { useLocalize, useCustomLink } from '~/hooks'; +import { cn } from '~/utils'; + +export default function BackToChat({ className }: { className?: string }) { + const localize = useLocalize(); + const { prevLocationPath } = useDashboardContext(); + + const conversationId = useMemo(() => { + if (!prevLocationPath || prevLocationPath.includes('/d/')) { + return 'new'; + } + const parts = prevLocationPath.split('/'); + return parts[parts.length - 1]; + }, [prevLocationPath]); + + const href = `/c/${conversationId}`; + const clickHandler = useCustomLink(href); + + return ( + + + ); +} diff --git a/client/src/components/Prompts/buttons/CreatePromptButton.tsx b/client/src/components/Prompts/buttons/CreatePromptButton.tsx new file mode 100644 index 0000000000..016721d509 --- /dev/null +++ b/client/src/components/Prompts/buttons/CreatePromptButton.tsx @@ -0,0 +1,37 @@ +import { Plus } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Button, TooltipAnchor } from '@librechat/client'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useHasAccess, useLocalize } from '~/hooks'; + +export default function CreatePromptButton() { + const localize = useLocalize(); + const hasCreateAccess = useHasAccess({ + permissionType: PermissionTypes.PROMPTS, + permission: Permissions.CREATE, + }); + + if (!hasCreateAccess) { + return null; + } + + return ( + + +