mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🗨️ refactor: Optimize Prompt Queries
feat: Refactor prompt and prompt group schemas; move types to separate file feat: Implement paginated access to prompt groups with filtering and public visibility refactor: Add PromptGroups context provider and integrate it into relevant components refactor: Optimize filter change handling and query invalidation in usePromptGroupsNav hook refactor: Simplify context usage in FilterPrompts and GroupSidePanel components
This commit is contained in:
parent
53c31b85d0
commit
dcd96c29c5
22 changed files with 583 additions and 259 deletions
|
|
@ -247,10 +247,130 @@ const deletePromptGroup = async ({ _id, author, role }) => {
|
||||||
return { message: 'Prompt group deleted successfully' };
|
return { message: 'Prompt group deleted successfully' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prompt groups by accessible IDs with optional cursor-based pagination.
|
||||||
|
* @param {Object} params - The parameters for getting accessible prompt groups.
|
||||||
|
* @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to.
|
||||||
|
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
|
||||||
|
* @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups.
|
||||||
|
* @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id.
|
||||||
|
* @returns {Promise<Object>} A promise that resolves to an object containing the prompt groups data and pagination info.
|
||||||
|
*/
|
||||||
|
async function getListPromptGroupsByAccess({
|
||||||
|
accessibleIds = [],
|
||||||
|
otherParams = {},
|
||||||
|
limit = null,
|
||||||
|
after = null,
|
||||||
|
}) {
|
||||||
|
const isPaginated = limit !== null && limit !== undefined;
|
||||||
|
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
|
||||||
|
|
||||||
|
// Build base query combining ACL accessible prompt groups with other filters
|
||||||
|
const baseQuery = { ...otherParams, _id: { $in: accessibleIds } };
|
||||||
|
|
||||||
|
// Add cursor condition
|
||||||
|
if (after) {
|
||||||
|
try {
|
||||||
|
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
|
||||||
|
const { updatedAt, _id } = cursor;
|
||||||
|
|
||||||
|
const cursorCondition = {
|
||||||
|
$or: [
|
||||||
|
{ updatedAt: { $lt: new Date(updatedAt) } },
|
||||||
|
{ updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge cursor condition with base query
|
||||||
|
if (Object.keys(baseQuery).length > 0) {
|
||||||
|
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
|
||||||
|
// Remove the original conditions from baseQuery to avoid duplication
|
||||||
|
Object.keys(baseQuery).forEach((key) => {
|
||||||
|
if (key !== '$and') delete baseQuery[key];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(baseQuery, cursorCondition);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Invalid cursor:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build aggregation pipeline
|
||||||
|
const pipeline = [{ $match: baseQuery }, { $sort: { updatedAt: -1, _id: 1 } }];
|
||||||
|
|
||||||
|
// Only apply limit if pagination is requested
|
||||||
|
if (isPaginated) {
|
||||||
|
pipeline.push({ $limit: normalizedLimit + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add lookup for production prompt
|
||||||
|
pipeline.push(
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'prompts',
|
||||||
|
localField: 'productionId',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'productionPrompt',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } },
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
name: 1,
|
||||||
|
numberOfGenerations: 1,
|
||||||
|
oneliner: 1,
|
||||||
|
category: 1,
|
||||||
|
projectIds: 1,
|
||||||
|
productionId: 1,
|
||||||
|
author: 1,
|
||||||
|
authorName: 1,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
'productionPrompt.prompt': 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const promptGroups = await PromptGroup.aggregate(pipeline).exec();
|
||||||
|
|
||||||
|
const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false;
|
||||||
|
const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map(
|
||||||
|
(group) => {
|
||||||
|
if (group.author) {
|
||||||
|
group.author = group.author.toString();
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate next cursor only if paginated
|
||||||
|
let nextCursor = null;
|
||||||
|
if (isPaginated && hasMore && data.length > 0) {
|
||||||
|
const lastGroup = promptGroups[normalizedLimit - 1];
|
||||||
|
nextCursor = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
updatedAt: lastGroup.updatedAt.toISOString(),
|
||||||
|
_id: lastGroup._id.toString(),
|
||||||
|
}),
|
||||||
|
).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
object: 'list',
|
||||||
|
data,
|
||||||
|
first_id: data.length > 0 ? data[0]._id.toString() : null,
|
||||||
|
last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null,
|
||||||
|
has_more: hasMore,
|
||||||
|
after: nextCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getPromptGroups,
|
getPromptGroups,
|
||||||
deletePromptGroup,
|
deletePromptGroup,
|
||||||
getAllPromptGroups,
|
getAllPromptGroups,
|
||||||
|
getListPromptGroupsByAccess,
|
||||||
/**
|
/**
|
||||||
* Create a prompt and its respective group
|
* Create a prompt and its respective group
|
||||||
* @param {TCreatePromptRecord} saveData
|
* @param {TCreatePromptRecord} saveData
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { generateCheckAccess } = require('@librechat/api');
|
const {
|
||||||
|
generateCheckAccess,
|
||||||
|
markPublicPromptGroups,
|
||||||
|
buildPromptGroupFilter,
|
||||||
|
formatPromptGroupsResponse,
|
||||||
|
createEmptyPromptGroupsResponse,
|
||||||
|
filterAccessibleIdsBySharedLogic,
|
||||||
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Permissions,
|
Permissions,
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
|
|
@ -11,12 +18,11 @@ const {
|
||||||
PermissionTypes,
|
PermissionTypes,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
|
getListPromptGroupsByAccess,
|
||||||
makePromptProduction,
|
makePromptProduction,
|
||||||
getAllPromptGroups,
|
|
||||||
updatePromptGroup,
|
updatePromptGroup,
|
||||||
deletePromptGroup,
|
deletePromptGroup,
|
||||||
createPromptGroup,
|
createPromptGroup,
|
||||||
getPromptGroups,
|
|
||||||
getPromptGroup,
|
getPromptGroup,
|
||||||
deletePrompt,
|
deletePrompt,
|
||||||
getPrompts,
|
getPrompts,
|
||||||
|
|
@ -95,23 +101,48 @@ router.get(
|
||||||
router.get('/all', async (req, res) => {
|
router.get('/all', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
const { name, category, ...otherFilters } = req.query;
|
||||||
|
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
...otherFilters,
|
||||||
|
});
|
||||||
|
|
||||||
// Get promptGroup IDs the user has VIEW access to via ACL
|
let accessibleIds = await findAccessibleResources({
|
||||||
const accessibleIds = await findAccessibleResources({
|
|
||||||
userId,
|
userId,
|
||||||
role: req.user.role,
|
role: req.user.role,
|
||||||
resourceType: ResourceType.PROMPTGROUP,
|
resourceType: ResourceType.PROMPTGROUP,
|
||||||
requiredPermissions: PermissionBits.VIEW,
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = await getAllPromptGroups(req, {});
|
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||||
|
resourceType: ResourceType.PROMPTGROUP,
|
||||||
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
|
});
|
||||||
|
|
||||||
// Filter the results to only include accessible groups
|
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
|
||||||
const accessibleGroups = groups.filter((group) =>
|
accessibleIds,
|
||||||
accessibleIds.some((id) => id.toString() === group._id.toString()),
|
searchShared,
|
||||||
);
|
searchSharedOnly,
|
||||||
|
publicPromptGroupIds: publiclyAccessibleIds,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).send(accessibleGroups);
|
const result = await getListPromptGroupsByAccess({
|
||||||
|
accessibleIds: filteredAccessibleIds,
|
||||||
|
otherParams: filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(200).send([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: promptGroups = [] } = result;
|
||||||
|
if (!promptGroups.length) {
|
||||||
|
return res.status(200).send([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
|
||||||
|
res.status(200).send(groupsWithPublicFlag);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
res.status(500).send({ error: 'Error getting prompt groups' });
|
res.status(500).send({ error: 'Error getting prompt groups' });
|
||||||
|
|
@ -125,40 +156,66 @@ router.get('/all', async (req, res) => {
|
||||||
router.get('/groups', async (req, res) => {
|
router.get('/groups', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const filter = { ...req.query };
|
const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query;
|
||||||
delete filter.author; // Remove author filter as we'll use ACL
|
|
||||||
|
|
||||||
// Get promptGroup IDs the user has VIEW access to via ACL
|
const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({
|
||||||
const accessibleIds = await findAccessibleResources({
|
name,
|
||||||
|
category,
|
||||||
|
...otherFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
let actualLimit = limit;
|
||||||
|
let actualCursor = cursor;
|
||||||
|
|
||||||
|
if (pageSize && !limit) {
|
||||||
|
actualLimit = parseInt(pageSize, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessibleIds = await findAccessibleResources({
|
||||||
userId,
|
userId,
|
||||||
role: req.user.role,
|
role: req.user.role,
|
||||||
resourceType: ResourceType.PROMPTGROUP,
|
resourceType: ResourceType.PROMPTGROUP,
|
||||||
requiredPermissions: PermissionBits.VIEW,
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get publicly accessible promptGroups
|
|
||||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||||
resourceType: ResourceType.PROMPTGROUP,
|
resourceType: ResourceType.PROMPTGROUP,
|
||||||
requiredPermissions: PermissionBits.VIEW,
|
requiredPermissions: PermissionBits.VIEW,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = await getPromptGroups(req, filter);
|
const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({
|
||||||
|
accessibleIds,
|
||||||
if (groups.promptGroups && groups.promptGroups.length > 0) {
|
searchShared,
|
||||||
groups.promptGroups = groups.promptGroups.filter((group) =>
|
searchSharedOnly,
|
||||||
accessibleIds.some((id) => id.toString() === group._id.toString()),
|
publicPromptGroupIds: publiclyAccessibleIds,
|
||||||
);
|
|
||||||
|
|
||||||
// Mark public groups
|
|
||||||
groups.promptGroups = groups.promptGroups.map((group) => {
|
|
||||||
if (publiclyAccessibleIds.some((id) => id.equals(group._id))) {
|
|
||||||
group.isPublic = true;
|
|
||||||
}
|
|
||||||
return group;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await getListPromptGroupsByAccess({
|
||||||
|
accessibleIds: filteredAccessibleIds,
|
||||||
|
otherParams: filter,
|
||||||
|
limit: actualLimit,
|
||||||
|
after: actualCursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit });
|
||||||
|
return res.status(200).send(emptyResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).send(groups);
|
const { data: promptGroups = [], has_more = false, after = null } = result;
|
||||||
|
|
||||||
|
const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds);
|
||||||
|
|
||||||
|
const response = formatPromptGroupsResponse({
|
||||||
|
promptGroups: groupsWithPublicFlag,
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
actualLimit,
|
||||||
|
hasMore: has_more,
|
||||||
|
after,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
res.status(500).send({ error: 'Error getting prompt groups' });
|
res.status(500).send({ error: 'Error getting prompt groups' });
|
||||||
|
|
@ -188,7 +245,6 @@ const createNewPromptGroup = async (req, res) => {
|
||||||
|
|
||||||
const result = await createPromptGroup(saveData);
|
const result = await createPromptGroup(saveData);
|
||||||
|
|
||||||
// Grant owner permissions to the creator on the new promptGroup
|
|
||||||
if (result.prompt && result.prompt._id && result.prompt.groupId) {
|
if (result.prompt && result.prompt._id && result.prompt.groupId) {
|
||||||
try {
|
try {
|
||||||
await grantPermission({
|
await grantPermission({
|
||||||
|
|
|
||||||
76
client/src/Providers/PromptGroupsContext.tsx
Normal file
76
client/src/Providers/PromptGroupsContext.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
|
||||||
|
import type { TPromptGroup } from 'librechat-data-provider';
|
||||||
|
import type { PromptOption } from '~/common';
|
||||||
|
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||||
|
import { useGetAllPromptGroups } from '~/data-provider';
|
||||||
|
import { usePromptGroupsNav } from '~/hooks';
|
||||||
|
import { mapPromptGroups } from '~/utils';
|
||||||
|
|
||||||
|
type AllPromptGroupsData =
|
||||||
|
| {
|
||||||
|
promptsMap: Record<string, TPromptGroup>;
|
||||||
|
promptGroups: PromptOption[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
type PromptGroupsContextType =
|
||||||
|
| (ReturnType<typeof usePromptGroupsNav> & {
|
||||||
|
allPromptGroups: {
|
||||||
|
data: AllPromptGroupsData;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
})
|
||||||
|
| null;
|
||||||
|
|
||||||
|
const PromptGroupsContext = createContext<PromptGroupsContextType>(null);
|
||||||
|
|
||||||
|
export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const promptGroupsNav = usePromptGroupsNav();
|
||||||
|
const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, {
|
||||||
|
select: (data) => {
|
||||||
|
const mappedArray: PromptOption[] = data.map((group) => ({
|
||||||
|
id: group._id ?? '',
|
||||||
|
type: 'prompt',
|
||||||
|
value: group.command ?? group.name,
|
||||||
|
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
||||||
|
group.name
|
||||||
|
}: ${
|
||||||
|
(group.oneliner?.length ?? 0) > 0
|
||||||
|
? group.oneliner
|
||||||
|
: (group.productionPrompt?.prompt ?? '')
|
||||||
|
}`,
|
||||||
|
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const promptsMap = mapPromptGroups(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptsMap,
|
||||||
|
promptGroups: mappedArray,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
...promptGroupsNav,
|
||||||
|
allPromptGroups: {
|
||||||
|
data: allGroupsData,
|
||||||
|
isLoading: isLoadingAll,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[promptGroupsNav, allGroupsData, isLoadingAll],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PromptGroupsContext.Provider value={contextValue}>{children}</PromptGroupsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePromptGroupsContext = () => {
|
||||||
|
const context = useContext(PromptGroupsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePromptGroupsContext must be used within a PromptGroupsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -24,4 +24,5 @@ export * from './SearchContext';
|
||||||
export * from './BadgeRowContext';
|
export * from './BadgeRowContext';
|
||||||
export * from './SidePanelContext';
|
export * from './SidePanelContext';
|
||||||
export * from './ArtifactsContext';
|
export * from './ArtifactsContext';
|
||||||
|
export * from './PromptGroupsContext';
|
||||||
export { default as BadgeRowProvider } from './BadgeRowContext';
|
export { default as BadgeRowProvider } from './BadgeRowContext';
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,10 @@ import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import type { TPromptGroup } from 'librechat-data-provider';
|
import type { TPromptGroup } from 'librechat-data-provider';
|
||||||
import type { PromptOption } from '~/common';
|
import type { PromptOption } from '~/common';
|
||||||
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
|
import { removeCharIfLast, detectVariables } from '~/utils';
|
||||||
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
||||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
import { usePromptGroupsContext } from '~/Providers';
|
||||||
import { useLocalize, useHasAccess } from '~/hooks';
|
import { useLocalize, useHasAccess } from '~/hooks';
|
||||||
import { useGetAllPromptGroups } from '~/data-provider';
|
|
||||||
import MentionItem from './MentionItem';
|
import MentionItem from './MentionItem';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -60,30 +59,8 @@ function PromptsCommand({
|
||||||
permission: Permissions.USE,
|
permission: Permissions.USE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useGetAllPromptGroups(undefined, {
|
const { allPromptGroups } = usePromptGroupsContext();
|
||||||
enabled: hasAccess,
|
const { data, isLoading } = allPromptGroups;
|
||||||
select: (data) => {
|
|
||||||
const mappedArray = data.map((group) => ({
|
|
||||||
id: group._id,
|
|
||||||
value: group.command ?? group.name,
|
|
||||||
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
|
|
||||||
group.name
|
|
||||||
}: ${
|
|
||||||
(group.oneliner?.length ?? 0) > 0
|
|
||||||
? group.oneliner
|
|
||||||
: (group.productionPrompt?.prompt ?? '')
|
|
||||||
}`,
|
|
||||||
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const promptsMap = mapPromptGroups(data);
|
|
||||||
|
|
||||||
return {
|
|
||||||
promptsMap,
|
|
||||||
promptGroups: mappedArray,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { TPromptGroup } from 'librechat-data-provider';
|
|
||||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
|
||||||
|
|
||||||
export default function PromptCard({ promptGroup }: { promptGroup?: TPromptGroup }) {
|
|
||||||
return (
|
|
||||||
<div className="hover:bg-token-main-surface-secondary relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-slate-100 dark:border-gray-600 dark:hover:bg-gray-700">
|
|
||||||
<div className="">
|
|
||||||
<CategoryIcon className="size-4" category={promptGroup?.category ?? ''} />
|
|
||||||
</div>
|
|
||||||
<p className="break-word line-clamp-3 text-balance text-gray-600 dark:text-gray-400">
|
|
||||||
{(promptGroup?.oneliner ?? '') || promptGroup?.productionPrompt?.prompt}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
||||||
import { usePromptGroupsNav } from '~/hooks';
|
|
||||||
import PromptCard from './PromptCard';
|
|
||||||
import { Button } from '../ui';
|
|
||||||
|
|
||||||
export default function Prompts() {
|
|
||||||
const { prevPage, nextPage, hasNextPage, promptGroups, hasPreviousPage, setPageSize, pageSize } =
|
|
||||||
usePromptGroupsNav();
|
|
||||||
|
|
||||||
const renderPromptCards = (start = 0, count) => {
|
|
||||||
return promptGroups
|
|
||||||
.slice(start, count + start)
|
|
||||||
.map((promptGroup) => <PromptCard key={promptGroup._id} promptGroup={promptGroup} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRows = () => {
|
|
||||||
switch (pageSize) {
|
|
||||||
case 4:
|
|
||||||
return [4];
|
|
||||||
case 8:
|
|
||||||
return [4, 4];
|
|
||||||
case 12:
|
|
||||||
return [4, 4, 4];
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = getRows();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-3 flex h-full max-w-3xl flex-col items-stretch justify-center gap-4">
|
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant={'ghost'}
|
|
||||||
onClick={() => setPageSize(4)}
|
|
||||||
className={`rounded px-3 py-2 hover:bg-transparent ${
|
|
||||||
pageSize === 4 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
4
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={'ghost'}
|
|
||||||
onClick={() => setPageSize(8)}
|
|
||||||
className={`rounded px-3 py-2 hover:bg-transparent ${
|
|
||||||
pageSize === 8 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
8
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={'ghost'}
|
|
||||||
onClick={() => setPageSize(12)}
|
|
||||||
className={`rounded p-2 hover:bg-transparent ${
|
|
||||||
pageSize === 12 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
12
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full flex-col items-start gap-2">
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'flex min-h-[121.1px] min-w-full max-w-3xl flex-col gap-4 overflow-y-auto md:min-w-[22rem] lg:min-w-[43rem]'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rows.map((rowSize, index) => (
|
|
||||||
<div key={index} className="flex flex-wrap justify-center gap-4">
|
|
||||||
{renderPromptCards(rowSize * index, rowSize)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full justify-between">
|
|
||||||
<Button
|
|
||||||
variant={'ghost'}
|
|
||||||
onClick={prevPage}
|
|
||||||
disabled={!hasPreviousPage}
|
|
||||||
className="m-0 self-start p-0 hover:bg-transparent"
|
|
||||||
aria-label="previous"
|
|
||||||
>
|
|
||||||
<ChevronLeft className={`${hasPreviousPage ? '' : 'text-gray-500'}`} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={'ghost'}
|
|
||||||
onClick={nextPage}
|
|
||||||
disabled={!hasNextPage}
|
|
||||||
className="m-0 self-end p-0 hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<ChevronRight className={`${hasNextPage ? '' : 'text-gray-500'}`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
import { ListFilter, User, Share2 } from 'lucide-react';
|
import { ListFilter, User, Share2 } from 'lucide-react';
|
||||||
import { SystemCategories } from 'librechat-data-provider';
|
import { SystemCategories } from 'librechat-data-provider';
|
||||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
|
||||||
import { Dropdown, AnimatedSearchInput } from '@librechat/client';
|
import { Dropdown, AnimatedSearchInput } from '@librechat/client';
|
||||||
import type { Option } from '~/common';
|
import type { Option } from '~/common';
|
||||||
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
|
import { useLocalize, useCategories } from '~/hooks';
|
||||||
|
import { usePromptGroupsContext } from '~/Providers';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function FilterPrompts({
|
export default function FilterPrompts({ className = '' }: { className?: string }) {
|
||||||
setName,
|
|
||||||
className = '',
|
|
||||||
}: Pick<ReturnType<typeof usePromptGroupsNav>, 'setName'> & {
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [displayName, setDisplayName] = useState('');
|
const { setName } = usePromptGroupsContext();
|
||||||
const setCategory = useSetRecoilState(store.promptsCategory);
|
|
||||||
const categoryFilter = useRecoilValue(store.promptsCategory);
|
|
||||||
const { categories } = useCategories('h-4 w-4');
|
const { categories } = useCategories('h-4 w-4');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory);
|
||||||
|
|
||||||
const filterOptions = useMemo(() => {
|
const filterOptions = useMemo(() => {
|
||||||
const baseOptions: Option[] = [
|
const baseOptions: Option[] = [
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,23 @@ import { useLocation } from 'react-router-dom';
|
||||||
import { useMediaQuery } from '@librechat/client';
|
import { useMediaQuery } from '@librechat/client';
|
||||||
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
||||||
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
||||||
|
import { usePromptGroupsContext } from '~/Providers';
|
||||||
import List from '~/components/Prompts/Groups/List';
|
import List from '~/components/Prompts/Groups/List';
|
||||||
import { usePromptGroupsNav } from '~/hooks';
|
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function GroupSidePanel({
|
export default function GroupSidePanel({
|
||||||
children,
|
children,
|
||||||
isDetailView,
|
isDetailView,
|
||||||
className = '',
|
className = '',
|
||||||
/* usePromptGroupsNav */
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
isDetailView?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
|
||||||
|
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
|
||||||
|
const {
|
||||||
nextPage,
|
nextPage,
|
||||||
prevPage,
|
prevPage,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
|
@ -19,14 +27,7 @@ export default function GroupSidePanel({
|
||||||
groupsQuery,
|
groupsQuery,
|
||||||
promptGroups,
|
promptGroups,
|
||||||
hasPreviousPage,
|
hasPreviousPage,
|
||||||
}: {
|
} = usePromptGroupsContext();
|
||||||
children?: React.ReactNode;
|
|
||||||
isDetailView?: boolean;
|
|
||||||
className?: string;
|
|
||||||
} & ReturnType<typeof usePromptGroupsNav>) {
|
|
||||||
const location = useLocation();
|
|
||||||
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
|
|
||||||
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,15 @@ import React from 'react';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Menu, Rocket } from 'lucide-react';
|
import { Menu, Rocket } from 'lucide-react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
import { useForm, FormProvider } from 'react-hook-form';
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
import { useParams, useOutletContext } from 'react-router-dom';
|
|
||||||
import { Button, Skeleton, useToastContext } from '@librechat/client';
|
import { Button, Skeleton, useToastContext } from '@librechat/client';
|
||||||
import { Permissions, PermissionTypes, PermissionBits } from 'librechat-data-provider';
|
import {
|
||||||
|
Permissions,
|
||||||
|
ResourceType,
|
||||||
|
PermissionBits,
|
||||||
|
PermissionTypes,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
|
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
useGetPrompts,
|
useGetPrompts,
|
||||||
|
|
@ -15,8 +20,9 @@ import {
|
||||||
useUpdatePromptGroup,
|
useUpdatePromptGroup,
|
||||||
useMakePromptProduction,
|
useMakePromptProduction,
|
||||||
} from '~/data-provider';
|
} from '~/data-provider';
|
||||||
import { useResourcePermissions, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
|
import { useResourcePermissions, useHasAccess, useLocalize } from '~/hooks';
|
||||||
import CategorySelector from './Groups/CategorySelector';
|
import CategorySelector from './Groups/CategorySelector';
|
||||||
|
import { usePromptGroupsContext } from '~/Providers';
|
||||||
import NoPromptGroup from './Groups/NoPromptGroup';
|
import NoPromptGroup from './Groups/NoPromptGroup';
|
||||||
import PromptVariables from './PromptVariables';
|
import PromptVariables from './PromptVariables';
|
||||||
import { cn, findPromptGroup } from '~/utils';
|
import { cn, findPromptGroup } from '~/utils';
|
||||||
|
|
@ -173,16 +179,14 @@ const PromptForm = () => {
|
||||||
const [showSidePanel, setShowSidePanel] = useState(false);
|
const [showSidePanel, setShowSidePanel] = useState(false);
|
||||||
const sidePanelWidth = '320px';
|
const sidePanelWidth = '320px';
|
||||||
|
|
||||||
// Fetch group early so it is available for later hooks.
|
|
||||||
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId);
|
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId);
|
||||||
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
|
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
|
||||||
{ groupId: promptId },
|
{ groupId: promptId },
|
||||||
{ enabled: !!promptId },
|
{ enabled: !!promptId },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check permissions for the promptGroup
|
|
||||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||||
'promptGroup',
|
ResourceType.PROMPTGROUP,
|
||||||
group?._id || '',
|
group?._id || '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -206,7 +210,7 @@ const PromptForm = () => {
|
||||||
|
|
||||||
const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
|
const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]);
|
||||||
|
|
||||||
const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>();
|
const { groupsQuery } = usePromptGroupsContext();
|
||||||
|
|
||||||
const updateGroupMutation = useUpdatePromptGroup({
|
const updateGroupMutation = useUpdatePromptGroup({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
|
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
|
||||||
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
|
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
|
||||||
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
||||||
import { usePromptGroupsNav } from '~/hooks';
|
import { usePromptGroupsContext } from '~/Providers';
|
||||||
|
|
||||||
export default function PromptsAccordion() {
|
export default function PromptsAccordion() {
|
||||||
const groupsNav = usePromptGroupsNav();
|
const groupsNav = usePromptGroupsContext();
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
|
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
||||||
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
|
import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb';
|
||||||
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
|
|
||||||
import GroupSidePanel from './Groups/GroupSidePanel';
|
import GroupSidePanel from './Groups/GroupSidePanel';
|
||||||
|
import { PromptGroupsProvider } from '~/Providers';
|
||||||
|
import { useHasAccess } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function PromptsView() {
|
export default function PromptsView() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const groupsNav = usePromptGroupsNav();
|
|
||||||
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
|
const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]);
|
||||||
const hasAccess = useHasAccess({
|
const hasAccess = useHasAccess({
|
||||||
permissionType: PermissionTypes.PROMPTS,
|
permissionType: PermissionTypes.PROMPTS,
|
||||||
|
|
@ -34,12 +34,13 @@ export default function PromptsView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PromptGroupsProvider>
|
||||||
<div className="flex h-screen w-full flex-col bg-surface-primary p-0 lg:p-2">
|
<div className="flex h-screen w-full flex-col bg-surface-primary p-0 lg:p-2">
|
||||||
<DashBreadcrumb />
|
<DashBreadcrumb />
|
||||||
<div className="flex w-full flex-grow flex-row divide-x overflow-hidden dark:divide-gray-600">
|
<div className="flex w-full flex-grow flex-row divide-x overflow-hidden dark:divide-gray-600">
|
||||||
<GroupSidePanel isDetailView={isDetailView} {...groupsNav}>
|
<GroupSidePanel isDetailView={isDetailView}>
|
||||||
<div className="mx-2 mt-1 flex flex-row items-center justify-between">
|
<div className="mx-2 mt-1 flex flex-row items-center justify-between">
|
||||||
<FilterPrompts setName={groupsNav.setName} />
|
<FilterPrompts />
|
||||||
</div>
|
</div>
|
||||||
</GroupSidePanel>
|
</GroupSidePanel>
|
||||||
<div
|
<div
|
||||||
|
|
@ -48,9 +49,10 @@ export default function PromptsView() {
|
||||||
isDetailView ? 'block' : 'hidden md:block',
|
isDetailView ? 'block' : 'hidden md:block',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Outlet context={groupsNav} />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PromptGroupsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { useMemo, useRef, useEffect } from 'react';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { useMemo, useRef, useEffect, useCallback } from 'react';
|
|
||||||
import { usePromptGroupsInfiniteQuery } from '~/data-provider';
|
import { usePromptGroupsInfiniteQuery } from '~/data-provider';
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import store from '~/store';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { QueryKeys } from 'librechat-data-provider';
|
import { QueryKeys } from 'librechat-data-provider';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
export default function usePromptGroupsNav() {
|
export default function usePromptGroupsNav() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -14,6 +14,7 @@ export default function usePromptGroupsNav() {
|
||||||
const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber);
|
const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber);
|
||||||
|
|
||||||
const maxPageNumberReached = useRef(1);
|
const maxPageNumberReached = useRef(1);
|
||||||
|
const prevFiltersRef = useRef({ name, category, pageSize });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) {
|
if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) {
|
||||||
|
|
@ -29,10 +30,25 @@ export default function usePromptGroupsNav() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const filtersChanged =
|
||||||
|
prevFiltersRef.current.name !== name ||
|
||||||
|
prevFiltersRef.current.category !== category ||
|
||||||
|
prevFiltersRef.current.pageSize !== pageSize;
|
||||||
|
|
||||||
|
if (!filtersChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
maxPageNumberReached.current = 1;
|
maxPageNumberReached.current = 1;
|
||||||
setPageNumber(1);
|
setPageNumber(1);
|
||||||
queryClient.resetQueries([QueryKeys.promptGroups, name, category, pageSize]);
|
|
||||||
}, [pageSize, name, category, setPageNumber]);
|
// Only reset queries if we're not already on page 1
|
||||||
|
// This prevents double queries when filters change
|
||||||
|
if (pageNumber !== 1) {
|
||||||
|
queryClient.invalidateQueries([QueryKeys.promptGroups, name, category, pageSize]);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevFiltersRef.current = { name, category, pageSize };
|
||||||
|
}, [pageSize, name, category, setPageNumber, pageNumber, queryClient]);
|
||||||
|
|
||||||
const promptGroups = useMemo(() => {
|
const promptGroups = useMemo(() => {
|
||||||
return groupsQuery.data?.pages[pageNumber - 1 + '']?.promptGroups || [];
|
return groupsQuery.data?.pages[pageNumber - 1 + '']?.promptGroups || [];
|
||||||
|
|
@ -52,7 +68,8 @@ export default function usePromptGroupsNav() {
|
||||||
const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber;
|
const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber;
|
||||||
const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1;
|
const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1;
|
||||||
|
|
||||||
const debouncedSetName = useCallback(
|
const debouncedSetName = useMemo(
|
||||||
|
() =>
|
||||||
debounce((nextValue: string) => {
|
debounce((nextValue: string) => {
|
||||||
setName(nextValue);
|
setName(nextValue);
|
||||||
}, 850),
|
}, 850),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
useFileMap,
|
useFileMap,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import {
|
import {
|
||||||
|
PromptGroupsProvider,
|
||||||
AssistantsMapContext,
|
AssistantsMapContext,
|
||||||
AgentsMapContext,
|
AgentsMapContext,
|
||||||
SetConvoProvider,
|
SetConvoProvider,
|
||||||
|
|
@ -68,6 +69,7 @@ export default function Root() {
|
||||||
<FileMapContext.Provider value={fileMap}>
|
<FileMapContext.Provider value={fileMap}>
|
||||||
<AssistantsMapContext.Provider value={assistantsMap}>
|
<AssistantsMapContext.Provider value={assistantsMap}>
|
||||||
<AgentsMapContext.Provider value={agentsMap}>
|
<AgentsMapContext.Provider value={agentsMap}>
|
||||||
|
<PromptGroupsProvider>
|
||||||
<Banner onHeightChange={setBannerHeight} />
|
<Banner onHeightChange={setBannerHeight} />
|
||||||
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
|
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
|
||||||
<div className="relative z-0 flex h-full w-full overflow-hidden">
|
<div className="relative z-0 flex h-full w-full overflow-hidden">
|
||||||
|
|
@ -78,6 +80,7 @@ export default function Root() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PromptGroupsProvider>
|
||||||
</AgentsMapContext.Provider>
|
</AgentsMapContext.Provider>
|
||||||
{config?.interface?.termsOfService?.modalAcceptance === true && (
|
{config?.interface?.termsOfService?.modalAcceptance === true && (
|
||||||
<TermsAndConditionsModal
|
<TermsAndConditionsModal
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './content';
|
export * from './content';
|
||||||
|
export * from './prompts';
|
||||||
|
|
|
||||||
150
packages/api/src/format/prompts.ts
Normal file
150
packages/api/src/format/prompts.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { SystemCategories } from 'librechat-data-provider';
|
||||||
|
import type { IPromptGroupDocument as IPromptGroup } from '@librechat/data-schemas';
|
||||||
|
import type { Types } from 'mongoose';
|
||||||
|
import type { PromptGroupsListResponse } from '~/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats prompt groups for the paginated /groups endpoint response
|
||||||
|
*/
|
||||||
|
export function formatPromptGroupsResponse({
|
||||||
|
promptGroups = [],
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
actualLimit,
|
||||||
|
hasMore = false,
|
||||||
|
after = null,
|
||||||
|
}: {
|
||||||
|
promptGroups: IPromptGroup[];
|
||||||
|
pageNumber?: string;
|
||||||
|
pageSize?: string;
|
||||||
|
actualLimit?: string | number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
after?: string | null;
|
||||||
|
}): PromptGroupsListResponse {
|
||||||
|
const effectivePageSize = parseInt(pageSize || '') || parseInt(String(actualLimit || '')) || 10;
|
||||||
|
const totalPages =
|
||||||
|
promptGroups.length > 0 ? Math.ceil(promptGroups.length / effectivePageSize).toString() : '0';
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptGroups,
|
||||||
|
pageNumber: pageNumber || '1',
|
||||||
|
pageSize: pageSize || String(actualLimit) || '10',
|
||||||
|
pages: totalPages,
|
||||||
|
has_more: hasMore,
|
||||||
|
after,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an empty response for the paginated /groups endpoint
|
||||||
|
*/
|
||||||
|
export function createEmptyPromptGroupsResponse({
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
actualLimit,
|
||||||
|
}: {
|
||||||
|
pageNumber?: string;
|
||||||
|
pageSize?: string;
|
||||||
|
actualLimit?: string | number;
|
||||||
|
}): PromptGroupsListResponse {
|
||||||
|
return {
|
||||||
|
promptGroups: [],
|
||||||
|
pageNumber: pageNumber || '1',
|
||||||
|
pageSize: pageSize || String(actualLimit) || '10',
|
||||||
|
pages: '0',
|
||||||
|
has_more: false,
|
||||||
|
after: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks prompt groups as public based on the publicly accessible IDs
|
||||||
|
*/
|
||||||
|
export function markPublicPromptGroups(
|
||||||
|
promptGroups: IPromptGroup[],
|
||||||
|
publiclyAccessibleIds: Types.ObjectId[],
|
||||||
|
): IPromptGroup[] {
|
||||||
|
if (!promptGroups.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return promptGroups.map((group) => {
|
||||||
|
const isPublic = publiclyAccessibleIds.some((id) => id.equals(group._id?.toString()));
|
||||||
|
return isPublic ? ({ ...group, isPublic: true } as IPromptGroup) : group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds filter object for prompt group queries
|
||||||
|
*/
|
||||||
|
export function buildPromptGroupFilter({
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
...otherFilters
|
||||||
|
}: {
|
||||||
|
name?: string;
|
||||||
|
category?: string;
|
||||||
|
[key: string]: string | number | boolean | RegExp | undefined;
|
||||||
|
}): {
|
||||||
|
filter: Record<string, string | number | boolean | RegExp | undefined>;
|
||||||
|
searchShared: boolean;
|
||||||
|
searchSharedOnly: boolean;
|
||||||
|
} {
|
||||||
|
const filter: Record<string, string | number | boolean | RegExp | undefined> = {
|
||||||
|
...otherFilters,
|
||||||
|
};
|
||||||
|
let searchShared = true;
|
||||||
|
let searchSharedOnly = false;
|
||||||
|
|
||||||
|
// Handle name filter - convert to regex for case-insensitive search
|
||||||
|
if (name) {
|
||||||
|
const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
filter.name = new RegExp(escapeRegExp(name), 'i');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle category filters with special system categories
|
||||||
|
if (category === SystemCategories.MY_PROMPTS) {
|
||||||
|
searchShared = false;
|
||||||
|
} else if (category === SystemCategories.NO_CATEGORY) {
|
||||||
|
filter.category = '';
|
||||||
|
} else if (category === SystemCategories.SHARED_PROMPTS) {
|
||||||
|
searchSharedOnly = true;
|
||||||
|
} else if (category) {
|
||||||
|
filter.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filter, searchShared, searchSharedOnly };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters accessible IDs based on shared/public prompts logic
|
||||||
|
*/
|
||||||
|
export async function filterAccessibleIdsBySharedLogic({
|
||||||
|
accessibleIds,
|
||||||
|
searchShared,
|
||||||
|
searchSharedOnly,
|
||||||
|
publicPromptGroupIds,
|
||||||
|
}: {
|
||||||
|
accessibleIds: Types.ObjectId[];
|
||||||
|
searchShared: boolean;
|
||||||
|
searchSharedOnly: boolean;
|
||||||
|
publicPromptGroupIds?: Types.ObjectId[];
|
||||||
|
}): Promise<Types.ObjectId[]> {
|
||||||
|
const publicIdStrings = new Set((publicPromptGroupIds || []).map((id) => id.toString()));
|
||||||
|
|
||||||
|
if (!searchShared) {
|
||||||
|
// For MY_PROMPTS - exclude public prompts to show only user's own prompts
|
||||||
|
return accessibleIds.filter((id) => !publicIdStrings.has(id.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchSharedOnly) {
|
||||||
|
// Handle SHARED_PROMPTS filter - only return public prompts that user has access to
|
||||||
|
if (!publicPromptGroupIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const accessibleIdStrings = new Set(accessibleIds.map((id) => id.toString()));
|
||||||
|
return publicPromptGroupIds.filter((id) => accessibleIdStrings.has(id.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...accessibleIds, ...(publicPromptGroupIds || [])];
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,6 @@ export * from './error';
|
||||||
export * from './google';
|
export * from './google';
|
||||||
export * from './mistral';
|
export * from './mistral';
|
||||||
export * from './openai';
|
export * from './openai';
|
||||||
|
export * from './prompts';
|
||||||
export * from './run';
|
export * from './run';
|
||||||
export * from './zod';
|
export * from './zod';
|
||||||
|
|
|
||||||
24
packages/api/src/types/prompts.ts
Normal file
24
packages/api/src/types/prompts.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { IPromptGroup as IPromptGroup } from '@librechat/data-schemas';
|
||||||
|
import type { Types } from 'mongoose';
|
||||||
|
|
||||||
|
export interface PromptGroupsListResponse {
|
||||||
|
promptGroups: IPromptGroup[];
|
||||||
|
pageNumber: string;
|
||||||
|
pageSize: string;
|
||||||
|
pages: string;
|
||||||
|
has_more: boolean;
|
||||||
|
after: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptGroupsAllResponse {
|
||||||
|
data: IPromptGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessiblePromptGroupsResult {
|
||||||
|
object: 'list';
|
||||||
|
data: IPromptGroup[];
|
||||||
|
first_id: Types.ObjectId | null;
|
||||||
|
last_id: Types.ObjectId | null;
|
||||||
|
has_more: boolean;
|
||||||
|
after: string | null;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import { Schema, Document, Types } from 'mongoose';
|
import { Schema } from 'mongoose';
|
||||||
|
import type { IPrompt } from '~/types';
|
||||||
export interface IPrompt extends Document {
|
|
||||||
groupId: Types.ObjectId;
|
|
||||||
author: Types.ObjectId;
|
|
||||||
prompt: string;
|
|
||||||
type: 'text' | 'chat';
|
|
||||||
createdAt?: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptSchema: Schema<IPrompt> = new Schema(
|
const promptSchema: Schema<IPrompt> = new Schema(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,6 @@
|
||||||
import { Schema, Document, Types } from 'mongoose';
|
import { Schema } from 'mongoose';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
|
import type { IPromptGroupDocument } from '~/types';
|
||||||
export interface IPromptGroup {
|
|
||||||
name: string;
|
|
||||||
numberOfGenerations: number;
|
|
||||||
oneliner: string;
|
|
||||||
category: string;
|
|
||||||
projectIds: Types.ObjectId[];
|
|
||||||
productionId: Types.ObjectId;
|
|
||||||
author: Types.ObjectId;
|
|
||||||
authorName: string;
|
|
||||||
command?: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IPromptGroupDocument extends IPromptGroup, Document {}
|
|
||||||
|
|
||||||
const promptGroupSchema = new Schema<IPromptGroupDocument>(
|
const promptGroupSchema = new Schema<IPromptGroupDocument>(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export * from './share';
|
||||||
export * from './pluginAuth';
|
export * from './pluginAuth';
|
||||||
/* Memories */
|
/* Memories */
|
||||||
export * from './memory';
|
export * from './memory';
|
||||||
|
/* Prompts */
|
||||||
|
export * from './prompts';
|
||||||
/* Access Control */
|
/* Access Control */
|
||||||
export * from './accessRole';
|
export * from './accessRole';
|
||||||
export * from './aclEntry';
|
export * from './aclEntry';
|
||||||
|
|
|
||||||
27
packages/data-schemas/src/types/prompts.ts
Normal file
27
packages/data-schemas/src/types/prompts.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Document, Types } from 'mongoose';
|
||||||
|
|
||||||
|
export interface IPrompt extends Document {
|
||||||
|
groupId: Types.ObjectId;
|
||||||
|
author: Types.ObjectId;
|
||||||
|
prompt: string;
|
||||||
|
type: 'text' | 'chat';
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPromptGroup {
|
||||||
|
name: string;
|
||||||
|
numberOfGenerations: number;
|
||||||
|
oneliner: string;
|
||||||
|
category: string;
|
||||||
|
projectIds: Types.ObjectId[];
|
||||||
|
productionId: Types.ObjectId;
|
||||||
|
author: Types.ObjectId;
|
||||||
|
authorName: string;
|
||||||
|
command?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPromptGroupDocument extends IPromptGroup, Document {}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue