mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🗨️ feat: Prompts (#3131)
* 🗨️ feat: Prompts (#7) * WIP: MERGE prompts/frontend (#1) * added schema for prompt and promptgroup, added model methods for prompts, added routes for prompts * * updated promptGroup Schema * updated model methods for prompts (get, add, delete) * slight fixes in prompt routes * * Created Files Management components * Created Vector Stores components * Added file management route in the routes folder * Completed UI for Files list, Compeleted UI for vector stores list, Completed UI for upload file modal, Completed UI for preview file, Completed UI for preview vector store * Fixed style and UI fixes for file dashboard, file list and vector stores list * added responsiveness classes for vector store page * fixed responsiveness of file page, dashboard page, and main page * fixed styling and responsiveness issues on dashboard page, file list page and vector store page * added queries and mutations for prompts and promptGroups, added relevant endpoints in data-provider, added relevant components prompts, added and updated relevant APIs * added types on mutation queries data service, updated prompt attributes * feature: Prompts and prompt groups management, added relevant APIs, added types for data service/queries/mutations, added relevant mutation and queries * chore: typing clarifications * added drop down on prompts mgmt dashboard * Fixes: fixed version switching issue on tags update or labels update, added cross button on create prompt group, fixed list updation on prompt group renaiming, added CSV upload button * Feature: Added oneliner and category attributes in prompt group, added schema for categories, added schema methods and route for categories * chore: typing and lint issues * chore: more type and linter fixes * chore: linting * chore: prompt controller and backend typing example; MOVE TO CONTROLLER DIRECTORY * chore: more type fixes * style: prompt name changes * chore: more type changes, and stateful prompt name change without flickering * fix: Return result of savePrompt in patchPrompt API endpoint * fix: navigation prompt queries; refactor: name 'prompt-groups' to just 'groups' * refactor: fetch prompt groups rewrite * refactor(prompts): query/mutation statefulness * refactor: remove `isActive` field * refactor: remove labels, consolidate logic * style: width, layout shift * refactor: improve hover toggle behavior and styling * refactor: add useParams hook to PromptListItem for dynamic rendering and add timeout ref for blur timeout * chore: hide upload button * refactor: import Button component from correct location in PromptSidePanel * style: prompt editor styling * style: fix more layout shifts * style: container scroll * refactor: Rename CreatePrompt component to CreatePromptForm * refactor: use react-hook-form * refactor: Add Prompts components and routes to Dashboard * style: skeletons for loading * fix: optimize makePromptProduction * refactor: consolidate variables * feat: create prompt form validation * refactor: Consolidate variables and update mutation hooks * style: minor touchups * chore: Update lucide-react npm dependency to version 0.394.0 and npm audit fix * refactor: add a new icon for the Prompts heading. * style: Update PromptsView heading to use h1 instead of h2 and other minor margin issues * chore: wording * refactor: Update PromptsView heading to use h1 instead of h2, consolidate variables, and add new icons * refactor: Prompts Button for Mobile * feature: added category field in prompt group, added relevant API and static data on BE to support FE UI for category in prompt group * chore: template for prompt cards --------- Co-authored-by: Fawadpot <contactfawada@gmail.com> * WIP: Prompts/frontend Continued (#2) * chore: loading style, remove unused component * feat: Add CategorySelector component for prompt group category selection * feat: add categories to create prompt * feat: prompt versions styling * feat: optimistic updates for prompt production state * refactor: optimize form state and show if prompt field is dirty with cross icon, also other styling changes * chore: remove unused code and localizations * fix: light mode styling * WIP: SidePanel Prompts * refactor: move to groups directory * refactor: rename GroupsSidePanel to GroupSidePanel and update imports * style: ListCard * refactor: isProduction changes * refactor: infinite query with productionPrompt * refactor: optimize snippets and prompts, and styling * refactor: Update getSnippet function to accept a length parameter * chore: localizations * feat: prompts navigation to chat and vice versa * fix: create prompt * feat: remember last selected category for creating prompts * fix(promptGroups): fix pagination and add usePromptGroupsNav hook * Prompts/frontend 3 (#3) * fix: stateful issues with prompt groups * style: improved layout * refactor: improve variable naming in Eng.ts * refactor: theme selector styling improvements * added prompt cards on chat new page, with dark mode, added API to fetch random prompts, added types for useQuery Slightly improved usePromptGroupNav logic to fetch updated result for pageSize, updated prompt cards view with darkmode and responsiveness fixed page size option buttons styling to match the theme added dark mode on create prompt page and prompt edit/preview page fixed page size option buttons styling to match the theme added dark mode on create prompt page and prompt edit/preview page * WIP: Prompts/frontend (#4) * fix: optimize and fix paginated query * fix: remove unique constraint on names * refactor: button links and styling * style: menu border light mode * feat: Add Auto-Send Switch component for prompts groups * refactor(ChatView): use form context for submission text * chore: clear convo state on navigation to dashboard routes * chore: save prompt edit name on tab, remove console log * feat: basic prompt submission * refactor: move Auto-Send Switch * style(ListCard): border styling * feat: Add function to detect variables in text * feat: Add OriginalDialog component to UI library * chore(ui): Update SelectDropDown options list class to use text-xs size * refactor: submitMessage hook now includes submitPrompt, make compatible to document query selector * WIP: Variable Dialog * feat: variable submission working for both auto-send and non-autosend * feat: dashboard breadcrumbs and prompts/chat navigation * refactor: dashboard breadcrumb and dashboard link to chat navigation * refactor: Update VariableDialog and VariableForm styles * Prompts: Admin features (#5) * fix: link issue * fix: usePromptGroupsNav add missing dep. * style: dashbreadcrumb and sidepanel text color * temp fix: remove refetch on pageNumber change * fix: handle multiple variable replacement * WIP: create project schema and add project groups to fetch * feat: Add functionality to add prompt group IDs to a project * feat: Add caching for startup config in config route * chore: remove prompt landing * style: Update Skeleton component with additional background styling * chore: styling and types * WIP: SharePrompt first draft * feat(SharePrompt): form validation * feat: shared global indicators * refactor: prompt details * refactor: change NoPromptGroup directory * feat: preview prompt * feat: remove/add global prompts, add rbac-related enums * refactor: manage prompts location * WIP: first draft admin settings for prompts * feat: SystemRoles enum * refactor: update PromptDetails component styling * style: ellipsis custom class for showing more preview text * WIP: initial role schema and initialization * style: improved margins for single unordered lists * fix: use custom chat form context to prevent re-renders from FormProvider * feat: Role mutations for Prompt Permissions * feat: fetch user role * feat: update AdminSettings form default values from user role values * refactor: rename PromptPermissions to Permissions for general definitions * feat: initial role checks * feat: Add optional `bodyProps` parameter to generateCheckAccess middleware * refactor: UI access checks * Prompts: delete (#6) * Fixed delete prompt version API, fixed types and logic for prompt version deletion, updated prompt delete mutation logic * chore: Update return type of deletePrompt function in Prompt.js --------- Co-authored-by: Fawadpot <contactfawada@gmail.com> * chore: Update package-lock.json version to 0.7.4-rc1 and fast-xml-parser to 4.4.0 * feat: toast for saving admin settings, add timer no-access navigation * feat: always make prod * feat: Add localization to category labels in CategorySelector component * feat: Update category label localization in CategorySelector component * fix: Enable making prompt production in Prompt API --------- Co-authored-by: Fawadpot <contactfawada@gmail.com> * feat: Add helper fn for dark mode detection in ThemeProvider * style: surface-primary definition * fix(useHasAccess): utilize user.role and not just USER role * fix: empty category and role fetch * refactort: increase max height to options list and use label if no localization is found * fix: update CategorySelector to handle empty category value and improve localization * refactor: move prompts to own store/reactquery modules, add in filter WIP * refactor: Rename AutoSendSwitch to AutoSendPrompt * style: theming commit * style: fix slight coloring issue for convos in dark mode * style: better composition for prompts side panel * style: remove gray-750 and make it gray-850 * chore: adjust theming * feat: filter all prompt groups and properly remove prompts from projects * refactor: optimize delete prompt groups further * chore: localization * feat: Add uniqueProperty filtering to normalizeData function * WIP: filter prompts * chore: Update FilterPrompts component to include User icon in FilterItem * feat(FilterPrompts): set categories * feat: more system filters and show selected category icon * style: always make prod, flips switch to avoid mis-clicks * style: ui/ux loading/no prompts * chore: style FilterPrompts ChatView * fix: handle missing role edge case * style: special variables * feat: special variables * refactor: improve replaceSpecialVars function in prompts.ts * feat: simple/advanced editor modes * chore: bump versions * feat: localizations and hide production button on simple mode * fix: error connecting layout shift * fix: prompts CRUD for admins * fix: secure single group fetch * style: sidepanel styling * style(PromptName): bring edit button closer to name * style: mobile prompts header * style: mobile prompts header continued * style: align send prompts switch right * feat: description * Update special variables description in Eng.ts * feat: update/create/preview oneliner * fix: allow empty oneliner update * style: loading improvement and always make selected prompt Production if simple mode * fix: production index set and remove unused props * fix(ci): mock initializeRoles * fix: address #3128 * fix: address #3128 * feat: add deletion confirmation dialog * fix: mobile UI issues * style: prompt library UI update * style: focus, logcal tab order * style: Refactor SelectDropDown component to improve code readability and maintainability * chore: bump data-provider * chore: fix labels * refactor: confirm delete prompt version --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
302b28fc9b
commit
0cd3c83328
216 changed files with 8741 additions and 797 deletions
5
api/cache/getLogStores.js
vendored
5
api/cache/getLogStores.js
vendored
|
@ -25,6 +25,10 @@ const config = isEnabled(USE_REDIS)
|
|||
? new Keyv({ store: keyvRedis })
|
||||
: new Keyv({ namespace: CacheKeys.CONFIG_STORE });
|
||||
|
||||
const roles = isEnabled(USE_REDIS)
|
||||
? new Keyv({ store: keyvRedis })
|
||||
: new Keyv({ namespace: CacheKeys.ROLES });
|
||||
|
||||
const audioRuns = isEnabled(USE_REDIS) // ttl: 30 minutes
|
||||
? new Keyv({ store: keyvRedis, ttl: TEN_MINUTES })
|
||||
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: TEN_MINUTES });
|
||||
|
@ -46,6 +50,7 @@ const abortKeys = isEnabled(USE_REDIS)
|
|||
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: 600000 });
|
||||
|
||||
const namespaces = {
|
||||
[CacheKeys.ROLES]: roles,
|
||||
[CacheKeys.CONFIG_STORE]: config,
|
||||
pending_req,
|
||||
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
|
||||
|
|
61
api/models/Categories.js
Normal file
61
api/models/Categories.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
const { logger } = require('~/config');
|
||||
// const { Categories } = require('./schema/categories');
|
||||
const options = [
|
||||
{
|
||||
label: '',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'idea',
|
||||
value: 'idea',
|
||||
},
|
||||
{
|
||||
label: 'travel',
|
||||
value: 'travel',
|
||||
},
|
||||
{
|
||||
label: 'teach_or_explain',
|
||||
value: 'teach_or_explain',
|
||||
},
|
||||
{
|
||||
label: 'write',
|
||||
value: 'write',
|
||||
},
|
||||
{
|
||||
label: 'shop',
|
||||
value: 'shop',
|
||||
},
|
||||
{
|
||||
label: 'code',
|
||||
value: 'code',
|
||||
},
|
||||
{
|
||||
label: 'misc',
|
||||
value: 'misc',
|
||||
},
|
||||
{
|
||||
label: 'roleplay',
|
||||
value: 'roleplay',
|
||||
},
|
||||
{
|
||||
label: 'finance',
|
||||
value: 'finance',
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Retrieves the categories asynchronously.
|
||||
* @returns {Promise<TGetCategoriesResponse>} An array of category objects.
|
||||
* @throws {Error} If there is an error retrieving the categories.
|
||||
*/
|
||||
getCategories: async () => {
|
||||
try {
|
||||
// const categories = await Categories.find();
|
||||
return options;
|
||||
} catch (error) {
|
||||
logger.error('Error getting categories', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
90
api/models/Project.js
Normal file
90
api/models/Project.js
Normal file
|
@ -0,0 +1,90 @@
|
|||
const { model } = require('mongoose');
|
||||
const projectSchema = require('~/models/schema/projectSchema');
|
||||
|
||||
const Project = model('Project', projectSchema);
|
||||
|
||||
/**
|
||||
* Retrieve a project by ID and convert the found project document to a plain object.
|
||||
*
|
||||
* @param {string} projectId - The ID of the project to find and return as a plain object.
|
||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
||||
* @returns {Promise<MongoProject>} A plain object representing the project document, or `null` if no project is found.
|
||||
*/
|
||||
const getProjectById = async function (projectId, fieldsToSelect = null) {
|
||||
const query = Project.findById(projectId);
|
||||
|
||||
if (fieldsToSelect) {
|
||||
query.select(fieldsToSelect);
|
||||
}
|
||||
|
||||
return await query.lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a project by name and convert the found project document to a plain object.
|
||||
* If the project with the given name doesn't exist and the name is "instance", create it and return the lean version.
|
||||
*
|
||||
* @param {string} projectName - The name of the project to find or create.
|
||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
||||
* @returns {Promise<MongoProject>} A plain object representing the project document.
|
||||
*/
|
||||
const getProjectByName = async function (projectName, fieldsToSelect = null) {
|
||||
const query = { name: projectName };
|
||||
const update = { $setOnInsert: { name: projectName } };
|
||||
const options = {
|
||||
new: true,
|
||||
upsert: projectName === 'instance',
|
||||
lean: true,
|
||||
select: fieldsToSelect,
|
||||
};
|
||||
|
||||
return await Project.findOneAndUpdate(query, update, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an array of prompt group IDs to a project's promptGroupIds array, ensuring uniqueness.
|
||||
*
|
||||
* @param {string} projectId - The ID of the project to update.
|
||||
* @param {string[]} promptGroupIds - The array of prompt group IDs to add to the project.
|
||||
* @returns {Promise<MongoProject>} The updated project document.
|
||||
*/
|
||||
const addGroupIdsToProject = async function (projectId, promptGroupIds) {
|
||||
return await Project.findByIdAndUpdate(
|
||||
projectId,
|
||||
{ $addToSet: { promptGroupIds: { $each: promptGroupIds } } },
|
||||
{ new: true },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an array of prompt group IDs from a project's promptGroupIds array.
|
||||
*
|
||||
* @param {string} projectId - The ID of the project to update.
|
||||
* @param {string[]} promptGroupIds - The array of prompt group IDs to remove from the project.
|
||||
* @returns {Promise<MongoProject>} The updated project document.
|
||||
*/
|
||||
const removeGroupIdsFromProject = async function (projectId, promptGroupIds) {
|
||||
return await Project.findByIdAndUpdate(
|
||||
projectId,
|
||||
{ $pull: { promptGroupIds: { $in: promptGroupIds } } },
|
||||
{ new: true },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a prompt group ID from all projects.
|
||||
*
|
||||
* @param {string} promptGroupId - The ID of the prompt group to remove from projects.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeGroupFromAllProjects = async (promptGroupId) => {
|
||||
await Project.updateMany({}, { $pull: { promptGroupIds: promptGroupId } });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getProjectById,
|
||||
getProjectByName,
|
||||
addGroupIdsToProject,
|
||||
removeGroupIdsFromProject,
|
||||
removeGroupFromAllProjects,
|
||||
};
|
|
@ -1,52 +1,435 @@
|
|||
const mongoose = require('mongoose');
|
||||
const { ObjectId } = require('mongodb');
|
||||
const { SystemRoles, SystemCategories } = require('librechat-data-provider');
|
||||
const {
|
||||
getProjectByName,
|
||||
addGroupIdsToProject,
|
||||
removeGroupIdsFromProject,
|
||||
removeGroupFromAllProjects,
|
||||
} = require('./Project');
|
||||
const { Prompt, PromptGroup } = require('./schema/promptSchema');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const promptSchema = mongoose.Schema(
|
||||
{
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
/**
|
||||
* Create a pipeline for the aggregation to get prompt groups
|
||||
* @param {Object} query
|
||||
* @param {number} skip
|
||||
* @param {number} limit
|
||||
* @returns {[Object]} - The pipeline for the aggregation
|
||||
*/
|
||||
const createGroupPipeline = (query, skip, limit) => {
|
||||
return [
|
||||
{ $match: query },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'prompts',
|
||||
localField: 'productionId',
|
||||
foreignField: '_id',
|
||||
as: 'productionPrompt',
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
type: String,
|
||||
required: true,
|
||||
{ $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,
|
||||
// 'productionPrompt._id': 1,
|
||||
// 'productionPrompt.type': 1,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
];
|
||||
};
|
||||
|
||||
const Prompt = mongoose.models.Prompt || mongoose.model('Prompt', promptSchema);
|
||||
/**
|
||||
* Get prompt groups with filters
|
||||
* @param {Object} req
|
||||
* @param {TPromptGroupsWithFilterRequest} filter
|
||||
* @returns {Promise<PromptGroupListResponse>}
|
||||
*/
|
||||
const getPromptGroups = async (req, filter) => {
|
||||
try {
|
||||
const { pageNumber = 1, pageSize = 10, name, ...query } = filter;
|
||||
if (!query.author) {
|
||||
throw new Error('Author is required');
|
||||
}
|
||||
|
||||
let searchShared = true;
|
||||
let searchSharedOnly = false;
|
||||
if (name) {
|
||||
query.name = new RegExp(name, 'i');
|
||||
}
|
||||
if (!query.category) {
|
||||
delete query.category;
|
||||
} else if (query.category === SystemCategories.MY_PROMPTS) {
|
||||
searchShared = false;
|
||||
delete query.category;
|
||||
} else if (query.category === SystemCategories.NO_CATEGORY) {
|
||||
query.category = '';
|
||||
} else if (query.category === SystemCategories.SHARED_PROMPTS) {
|
||||
searchSharedOnly = true;
|
||||
delete query.category;
|
||||
}
|
||||
|
||||
let combinedQuery = query;
|
||||
|
||||
if (searchShared) {
|
||||
// const projects = req.user.projects || []; // TODO: handle multiple projects
|
||||
const project = await getProjectByName('instance', 'promptGroupIds');
|
||||
if (project && project.promptGroupIds.length > 0) {
|
||||
const projectQuery = { _id: { $in: project.promptGroupIds }, ...query };
|
||||
delete projectQuery.author;
|
||||
combinedQuery = searchSharedOnly ? projectQuery : { $or: [projectQuery, query] };
|
||||
}
|
||||
}
|
||||
|
||||
const skip = (parseInt(pageNumber, 10) - 1) * parseInt(pageSize, 10);
|
||||
const limit = parseInt(pageSize, 10);
|
||||
|
||||
const promptGroupsPipeline = createGroupPipeline(combinedQuery, skip, limit);
|
||||
const totalPromptGroupsPipeline = [{ $match: combinedQuery }, { $count: 'total' }];
|
||||
|
||||
const [promptGroupsResults, totalPromptGroupsResults] = await Promise.all([
|
||||
PromptGroup.aggregate(promptGroupsPipeline).exec(),
|
||||
PromptGroup.aggregate(totalPromptGroupsPipeline).exec(),
|
||||
]);
|
||||
|
||||
const promptGroups = promptGroupsResults;
|
||||
const totalPromptGroups =
|
||||
totalPromptGroupsResults.length > 0 ? totalPromptGroupsResults[0].total : 0;
|
||||
|
||||
return {
|
||||
promptGroups,
|
||||
pageNumber: pageNumber.toString(),
|
||||
pageSize: pageSize.toString(),
|
||||
pages: Math.ceil(totalPromptGroups / pageSize).toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting prompt groups', error);
|
||||
return { message: 'Error getting prompt groups' };
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
savePrompt: async ({ title, prompt }) => {
|
||||
getPromptGroups,
|
||||
/**
|
||||
* Create a prompt and its respective group
|
||||
* @param {TCreatePromptRecord} saveData
|
||||
* @returns {Promise<TCreatePromptResponse>}
|
||||
*/
|
||||
createPromptGroup: async (saveData) => {
|
||||
try {
|
||||
await Prompt.create({
|
||||
title,
|
||||
prompt,
|
||||
});
|
||||
return { title, prompt };
|
||||
const { prompt, group, author, authorName } = saveData;
|
||||
|
||||
let newPromptGroup = await PromptGroup.findOneAndUpdate(
|
||||
{ ...group, author, authorName, productionId: null },
|
||||
{ $setOnInsert: { ...group, author, authorName, productionId: null } },
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.lean()
|
||||
.select('-__v')
|
||||
.exec();
|
||||
|
||||
const newPrompt = await Prompt.findOneAndUpdate(
|
||||
{ ...prompt, author, groupId: newPromptGroup._id },
|
||||
{ $setOnInsert: { ...prompt, author, groupId: newPromptGroup._id } },
|
||||
{ new: true, upsert: true },
|
||||
)
|
||||
.lean()
|
||||
.select('-__v')
|
||||
.exec();
|
||||
|
||||
newPromptGroup = await PromptGroup.findByIdAndUpdate(
|
||||
newPromptGroup._id,
|
||||
{ productionId: newPrompt._id },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.select('-__v')
|
||||
.exec();
|
||||
|
||||
return {
|
||||
prompt: newPrompt,
|
||||
group: {
|
||||
...newPromptGroup,
|
||||
productionPrompt: { prompt: newPrompt.prompt },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error saving prompt group', error);
|
||||
throw new Error('Error saving prompt group');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Save a prompt
|
||||
* @param {TCreatePromptRecord} saveData
|
||||
* @returns {Promise<TCreatePromptResponse>}
|
||||
*/
|
||||
savePrompt: async (saveData) => {
|
||||
try {
|
||||
const { prompt, author } = saveData;
|
||||
const newPromptData = {
|
||||
...prompt,
|
||||
author,
|
||||
};
|
||||
|
||||
/** @type {TPrompt} */
|
||||
let newPrompt;
|
||||
try {
|
||||
newPrompt = await Prompt.create(newPromptData);
|
||||
} catch (error) {
|
||||
if (error?.message?.includes('groupId_1_version_1')) {
|
||||
await Prompt.db.collection('prompts').dropIndex('groupId_1_version_1');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
newPrompt = await Prompt.create(newPromptData);
|
||||
}
|
||||
|
||||
return { prompt: newPrompt };
|
||||
} catch (error) {
|
||||
logger.error('Error saving prompt', error);
|
||||
return { prompt: 'Error saving prompt' };
|
||||
return { message: 'Error saving prompt' };
|
||||
}
|
||||
},
|
||||
getPrompts: async (filter) => {
|
||||
try {
|
||||
return await Prompt.find(filter).lean();
|
||||
return await Prompt.find(filter).sort({ createdAt: -1 }).lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompts', error);
|
||||
return { prompt: 'Error getting prompts' };
|
||||
return { message: 'Error getting prompts' };
|
||||
}
|
||||
},
|
||||
deletePrompts: async (filter) => {
|
||||
getPrompt: async (filter) => {
|
||||
try {
|
||||
return await Prompt.deleteMany(filter);
|
||||
if (filter.groupId) {
|
||||
filter.groupId = new ObjectId(filter.groupId);
|
||||
}
|
||||
return await Prompt.findOne(filter).lean();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting prompts', error);
|
||||
return { prompt: 'Error deleting prompts' };
|
||||
logger.error('Error getting prompt', error);
|
||||
return { message: 'Error getting prompt' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get prompt groups with filters
|
||||
* @param {TGetRandomPromptsRequest} filter
|
||||
* @returns {Promise<TGetRandomPromptsResponse>}
|
||||
*/
|
||||
getRandomPromptGroups: async (filter) => {
|
||||
try {
|
||||
const result = await PromptGroup.aggregate([
|
||||
{
|
||||
$match: {
|
||||
category: { $ne: '' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$category',
|
||||
promptGroup: { $first: '$$ROOT' },
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceRoot: { newRoot: '$promptGroup' },
|
||||
},
|
||||
{
|
||||
$sample: { size: +filter.limit + +filter.skip },
|
||||
},
|
||||
{
|
||||
$skip: +filter.skip,
|
||||
},
|
||||
{
|
||||
$limit: +filter.limit,
|
||||
},
|
||||
]);
|
||||
return { prompts: result };
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt groups', error);
|
||||
return { message: 'Error getting prompt groups' };
|
||||
}
|
||||
},
|
||||
getPromptGroupsWithPrompts: async (filter) => {
|
||||
try {
|
||||
return await PromptGroup.findOne(filter)
|
||||
.populate({
|
||||
path: 'prompts',
|
||||
select: '-_id -__v -user',
|
||||
})
|
||||
.select('-_id -__v -user')
|
||||
.lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt groups', error);
|
||||
return { message: 'Error getting prompt groups' };
|
||||
}
|
||||
},
|
||||
getPromptGroup: async (filter) => {
|
||||
try {
|
||||
return await PromptGroup.findOne(filter).lean();
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt group', error);
|
||||
return { message: 'Error getting prompt group' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes a prompt and its corresponding prompt group if it is the last prompt in the group.
|
||||
*
|
||||
* @param {Object} options - The options for deleting the prompt.
|
||||
* @param {ObjectId|string} options.promptId - The ID of the prompt to delete.
|
||||
* @param {ObjectId|string} options.groupId - The ID of the prompt's group.
|
||||
* @param {ObjectId|string} options.author - The ID of the prompt's author.
|
||||
* @param {string} options.role - The role of the prompt's author.
|
||||
* @return {Promise<TDeletePromptResponse>} An object containing the result of the deletion.
|
||||
* If the prompt was deleted successfully, the object will have a property 'prompt' with the value 'Prompt deleted successfully'.
|
||||
* If the prompt group was deleted successfully, the object will have a property 'promptGroup' with the message 'Prompt group deleted successfully' and id of the deleted group.
|
||||
* If there was an error deleting the prompt, the object will have a property 'message' with the value 'Error deleting prompt'.
|
||||
*/
|
||||
deletePrompt: async ({ promptId, groupId, author, role }) => {
|
||||
const query = { _id: promptId, groupId, author };
|
||||
if (role === SystemRoles.ADMIN) {
|
||||
delete query.author;
|
||||
}
|
||||
const { deletedCount } = await Prompt.deleteOne(query);
|
||||
if (deletedCount === 0) {
|
||||
throw new Error('Failed to delete the prompt');
|
||||
}
|
||||
|
||||
const remainingPrompts = await Prompt.find({ groupId })
|
||||
.select('_id')
|
||||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
if (remainingPrompts.length === 0) {
|
||||
await PromptGroup.deleteOne({ _id: groupId });
|
||||
await removeGroupFromAllProjects(groupId);
|
||||
|
||||
return {
|
||||
prompt: 'Prompt deleted successfully',
|
||||
promptGroup: {
|
||||
message: 'Prompt group deleted successfully',
|
||||
id: groupId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const promptGroup = await PromptGroup.findById(groupId).lean();
|
||||
if (promptGroup.productionId.toString() === promptId.toString()) {
|
||||
await PromptGroup.updateOne(
|
||||
{ _id: groupId },
|
||||
{ productionId: remainingPrompts[remainingPrompts.length - 1]._id },
|
||||
);
|
||||
}
|
||||
|
||||
return { prompt: 'Prompt deleted successfully' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Update prompt group
|
||||
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
|
||||
* @param {Partial<MongoPromptGroup>} data - Data to update
|
||||
* @returns {Promise<TUpdatePromptGroupResponse>}
|
||||
*/
|
||||
updatePromptGroup: async (filter, data) => {
|
||||
try {
|
||||
const updateOps = {};
|
||||
if (data.removeProjectIds) {
|
||||
for (const projectId of data.removeProjectIds) {
|
||||
await removeGroupIdsFromProject(projectId, [filter._id]);
|
||||
}
|
||||
|
||||
updateOps.$pull = { projectIds: { $in: data.removeProjectIds } };
|
||||
delete data.removeProjectIds;
|
||||
}
|
||||
|
||||
if (data.projectIds) {
|
||||
for (const projectId of data.projectIds) {
|
||||
await addGroupIdsToProject(projectId, [filter._id]);
|
||||
}
|
||||
|
||||
updateOps.$addToSet = { projectIds: { $each: data.projectIds } };
|
||||
delete data.projectIds;
|
||||
}
|
||||
|
||||
const updateData = { ...data, ...updateOps };
|
||||
const updatedDoc = await PromptGroup.findOneAndUpdate(filter, updateData, {
|
||||
new: true,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (!updatedDoc) {
|
||||
throw new Error('Prompt group not found');
|
||||
}
|
||||
|
||||
return updatedDoc;
|
||||
} catch (error) {
|
||||
logger.error('Error updating prompt group', error);
|
||||
return { message: 'Error updating prompt group' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Function to make a prompt production based on its ID.
|
||||
* @param {String} promptId - The ID of the prompt to make production.
|
||||
* @returns {Object} The result of the production operation.
|
||||
*/
|
||||
makePromptProduction: async (promptId) => {
|
||||
try {
|
||||
const prompt = await Prompt.findById(promptId).lean();
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error('Prompt not found');
|
||||
}
|
||||
|
||||
await PromptGroup.findByIdAndUpdate(
|
||||
prompt.groupId,
|
||||
{ productionId: prompt._id },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return {
|
||||
message: 'Prompt production made successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error making prompt production', error);
|
||||
return { message: 'Error making prompt production' };
|
||||
}
|
||||
},
|
||||
updatePromptLabels: async (_id, labels) => {
|
||||
try {
|
||||
const response = await Prompt.updateOne({ _id }, { $set: { labels } });
|
||||
if (response.matchedCount === 0) {
|
||||
return { message: 'Prompt not found' };
|
||||
}
|
||||
return { message: 'Prompt labels updated successfully' };
|
||||
} catch (error) {
|
||||
logger.error('Error updating prompt labels', error);
|
||||
return { message: 'Error updating prompt labels' };
|
||||
}
|
||||
},
|
||||
deletePromptGroup: async (_id) => {
|
||||
try {
|
||||
const response = await PromptGroup.deleteOne({ _id });
|
||||
|
||||
if (response.deletedCount === 0) {
|
||||
return { promptGroup: 'Prompt group not found' };
|
||||
}
|
||||
|
||||
await Prompt.deleteMany({ groupId: new ObjectId(_id) });
|
||||
await removeGroupFromAllProjects(_id);
|
||||
return { promptGroup: 'Prompt group deleted successfully' };
|
||||
} catch (error) {
|
||||
logger.error('Error deleting prompt group', error);
|
||||
return { message: 'Error deleting prompt group' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
86
api/models/Role.js
Normal file
86
api/models/Role.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
const { SystemRoles, CacheKeys, roleDefaults } = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const Role = require('~/models/schema/roleSchema');
|
||||
|
||||
/**
|
||||
* Retrieve a role by name and convert the found role document to a plain object.
|
||||
* If the role with the given name doesn't exist and the name is a system defined role, create it and return the lean version.
|
||||
*
|
||||
* @param {string} roleName - The name of the role to find or create.
|
||||
* @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document.
|
||||
* @returns {Promise<Object>} A plain object representing the role document.
|
||||
*/
|
||||
const getRoleByName = async function (roleName, fieldsToSelect = null) {
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.ROLES);
|
||||
const cachedRole = await cache.get(roleName);
|
||||
if (cachedRole) {
|
||||
return cachedRole;
|
||||
}
|
||||
let query = Role.findOne({ name: roleName });
|
||||
if (fieldsToSelect) {
|
||||
query = query.select(fieldsToSelect);
|
||||
}
|
||||
let role = await query.lean().exec();
|
||||
|
||||
if (!role && SystemRoles[roleName]) {
|
||||
role = roleDefaults[roleName];
|
||||
role = await new Role(role).save();
|
||||
await cache.set(roleName, role);
|
||||
return role.toObject();
|
||||
}
|
||||
await cache.set(roleName, role);
|
||||
return role;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to retrieve or create role: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update role values by name.
|
||||
*
|
||||
* @param {string} roleName - The name of the role to update.
|
||||
* @param {Partial<TRole>} updates - The fields to update.
|
||||
* @returns {Promise<TRole>} Updated role document.
|
||||
*/
|
||||
const updateRoleByName = async function (roleName, updates) {
|
||||
try {
|
||||
const cache = getLogStores(CacheKeys.ROLES);
|
||||
const role = await Role.findOneAndUpdate(
|
||||
{ name: roleName },
|
||||
{ $set: updates },
|
||||
{ new: true, lean: true },
|
||||
)
|
||||
.select('-__v')
|
||||
.lean()
|
||||
.exec();
|
||||
await cache.set(roleName, role);
|
||||
return role;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update role: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize default roles in the system.
|
||||
* Creates the default roles (ADMIN, USER) if they don't exist in the database.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const initializeRoles = async function () {
|
||||
const defaultRoles = [SystemRoles.ADMIN, SystemRoles.USER];
|
||||
|
||||
for (const roleName of defaultRoles) {
|
||||
let role = await Role.findOne({ name: roleName }).select('name').lean();
|
||||
if (!role) {
|
||||
role = new Role(roleDefaults[roleName]);
|
||||
await role.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getRoleByName,
|
||||
initializeRoles,
|
||||
updateRoleByName,
|
||||
};
|
19
api/models/schema/categories.js
Normal file
19
api/models/schema/categories.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
const mongoose = require('mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
const categoriesSchema = new Schema({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
});
|
||||
|
||||
const categories = mongoose.model('categories', categoriesSchema);
|
||||
|
||||
module.exports = { Categories: categories };
|
30
api/models/schema/projectSchema.js
Normal file
30
api/models/schema/projectSchema.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
const { Schema } = require('mongoose');
|
||||
|
||||
/**
|
||||
* @typedef {Object} MongoProject
|
||||
* @property {ObjectId} [_id] - MongoDB Document ID
|
||||
* @property {string} name - The name of the project
|
||||
* @property {ObjectId[]} promptGroupIds - Array of PromptGroup IDs associated with the project
|
||||
* @property {Date} [createdAt] - Date when the project was created (added by timestamps)
|
||||
* @property {Date} [updatedAt] - Date when the project was last updated (added by timestamps)
|
||||
*/
|
||||
|
||||
const projectSchema = new Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
promptGroupIds: {
|
||||
type: [Schema.Types.ObjectId],
|
||||
ref: 'PromptGroup',
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = projectSchema;
|
101
api/models/schema/promptSchema.js
Normal file
101
api/models/schema/promptSchema.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
const mongoose = require('mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
/**
|
||||
* @typedef {Object} MongoPromptGroup
|
||||
* @property {ObjectId} [_id] - MongoDB Document ID
|
||||
* @property {string} name - The name of the prompt group
|
||||
* @property {ObjectId} author - The author of the prompt group
|
||||
* @property {ObjectId} [projectId=null] - The project ID of the prompt group
|
||||
* @property {ObjectId} [productionId=null] - The project ID of the prompt group
|
||||
* @property {string} authorName - The name of the author of the prompt group
|
||||
* @property {number} [numberOfGenerations=0] - Number of generations the prompt group has
|
||||
* @property {string} [oneliner=''] - Oneliner description of the prompt group
|
||||
* @property {string} [category=''] - Category of the prompt group
|
||||
* @property {Date} [createdAt] - Date when the prompt group was created (added by timestamps)
|
||||
* @property {Date} [updatedAt] - Date when the prompt group was last updated (added by timestamps)
|
||||
*/
|
||||
|
||||
const promptGroupSchema = new Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
numberOfGenerations: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
oneliner: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: '',
|
||||
index: true,
|
||||
},
|
||||
projectIds: {
|
||||
type: [Schema.Types.ObjectId],
|
||||
ref: 'Project',
|
||||
index: true,
|
||||
},
|
||||
productionId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Prompt',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
author: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
authorName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
const PromptGroup = mongoose.model('PromptGroup', promptGroupSchema);
|
||||
|
||||
const promptSchema = new Schema(
|
||||
{
|
||||
groupId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'PromptGroup',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
author: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
},
|
||||
prompt: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['text', 'chat'],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
const Prompt = mongoose.model('Prompt', promptSchema);
|
||||
|
||||
promptSchema.index({ createdAt: 1, updatedAt: 1 });
|
||||
promptGroupSchema.index({ createdAt: 1, updatedAt: 1 });
|
||||
|
||||
module.exports = { Prompt, PromptGroup };
|
29
api/models/schema/roleSchema.js
Normal file
29
api/models/schema/roleSchema.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const roleSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.SHARED_GLOBAL]: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
[Permissions.USE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
[Permissions.CREATE]: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Role = mongoose.model('Role', roleSchema);
|
||||
|
||||
module.exports = Role;
|
|
@ -1,4 +1,5 @@
|
|||
const mongoose = require('mongoose');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* @typedef {Object} MongoSession
|
||||
|
@ -78,7 +79,7 @@ const userSchema = mongoose.Schema(
|
|||
},
|
||||
role: {
|
||||
type: String,
|
||||
default: 'USER',
|
||||
default: SystemRoles.USER,
|
||||
},
|
||||
googleId: {
|
||||
type: String,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.4-rc1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
const {
|
||||
EModelEndpoint,
|
||||
CacheKeys,
|
||||
defaultAssistantsVersion,
|
||||
SystemRoles,
|
||||
EModelEndpoint,
|
||||
defaultOrderQuery,
|
||||
defaultAssistantsVersion,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
initializeClient: initAzureClient,
|
||||
|
@ -227,7 +228,7 @@ const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
|
|||
body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
|
||||
}
|
||||
|
||||
if (req.user.role === 'ADMIN') {
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
return body;
|
||||
} else if (!req.app.locals[endpoint]) {
|
||||
return body;
|
||||
|
|
|
@ -81,6 +81,7 @@ const startServer = async () => {
|
|||
app.use('/api/convos', routes.convos);
|
||||
app.use('/api/presets', routes.presets);
|
||||
app.use('/api/prompts', routes.prompts);
|
||||
app.use('/api/categories', routes.categories);
|
||||
app.use('/api/tokenizer', routes.tokenizer);
|
||||
app.use('/api/endpoints', routes.endpoints);
|
||||
app.use('/api/balance', routes.balance);
|
||||
|
@ -91,6 +92,7 @@ const startServer = async () => {
|
|||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/images/', validateImageRequest, routes.staticRoute);
|
||||
app.use('/api/share', routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.sendFile(path.join(app.locals.paths.dist, 'index.html'));
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
|
||||
/**
|
||||
|
@ -11,7 +12,7 @@ const { getAssistant } = require('~/models/Assistant');
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistantId }) => {
|
||||
if (req.user.role === 'ADMIN') {
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
|
@ -16,7 +17,7 @@ const { logger } = require('~/config');
|
|||
const canDeleteAccount = async (req, res, next = () => {}) => {
|
||||
const { user } = req;
|
||||
const { ALLOW_ACCOUNT_DELETION = true } = process.env;
|
||||
if (user?.role === 'ADMIN' || isEnabled(ALLOW_ACCOUNT_DELETION)) {
|
||||
if (user?.role === SystemRoles.ADMIN || isEnabled(ALLOW_ACCOUNT_DELETION)) {
|
||||
return next();
|
||||
} else {
|
||||
logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`);
|
||||
|
|
|
@ -18,10 +18,12 @@ const limiters = require('./limiters');
|
|||
const uaParser = require('./uaParser');
|
||||
const checkBan = require('./checkBan');
|
||||
const noIndex = require('./noIndex');
|
||||
const roles = require('./roles');
|
||||
|
||||
module.exports = {
|
||||
...abortMiddleware,
|
||||
...limiters,
|
||||
...roles,
|
||||
noIndex,
|
||||
checkBan,
|
||||
uaParser,
|
||||
|
|
14
api/server/middleware/roles/checkAdmin.js
Normal file
14
api/server/middleware/roles/checkAdmin.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
const { SystemRoles } = require('librechat-data-provider');
|
||||
|
||||
function checkAdmin(req, res, next) {
|
||||
try {
|
||||
if (req.user.role !== SystemRoles.ADMIN) {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = checkAdmin;
|
52
api/server/middleware/roles/generateCheckAccess.js
Normal file
52
api/server/middleware/roles/generateCheckAccess.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
/**
|
||||
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
|
||||
*
|
||||
* @param {PermissionTypes} permissionType - The type of permission to check.
|
||||
* @param {Permissions[]} permissions - The list of specific permissions to check.
|
||||
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
|
||||
* @returns {Function} Express middleware function.
|
||||
*/
|
||||
const generateCheckAccess = (permissionType, permissions, bodyProps = {}) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const { user } = req;
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Authorization required' });
|
||||
}
|
||||
|
||||
if (user.role === SystemRoles.ADMIN) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const role = await getRoleByName(user.role);
|
||||
if (role && role[permissionType]) {
|
||||
const hasAnyPermission = permissions.some((permission) => {
|
||||
if (role[permissionType][permission]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (bodyProps[permission] && req.body) {
|
||||
return bodyProps[permission].some((prop) =>
|
||||
Object.prototype.hasOwnProperty.call(req.body, prop),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasAnyPermission) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: `Server error: ${error.message}` });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = generateCheckAccess;
|
7
api/server/middleware/roles/index.js
Normal file
7
api/server/middleware/roles/index.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
const checkAdmin = require('./checkAdmin');
|
||||
const generateCheckAccess = require('./generateCheckAccess');
|
||||
|
||||
module.exports = {
|
||||
checkAdmin,
|
||||
generateCheckAccess,
|
||||
};
|
15
api/server/routes/categories.js
Normal file
15
api/server/routes/categories.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { getCategories } = require('~/models/Categories');
|
||||
|
||||
router.get('/', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const categories = await getCategories();
|
||||
res.status(200).send(categories);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: 'Failed to retrieve categories', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -1,6 +1,8 @@
|
|||
const express = require('express');
|
||||
const { defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
@ -17,11 +19,20 @@ const publicSharedLinksEnabled =
|
|||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
|
||||
|
||||
router.get('/', async function (req, res) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||
if (cachedStartupConfig) {
|
||||
res.send(cachedStartupConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const isBirthday = () => {
|
||||
const today = new Date();
|
||||
return today.getMonth() === 1 && today.getDate() === 11;
|
||||
};
|
||||
|
||||
const instanceProject = await getProjectByName('instance', '_id');
|
||||
|
||||
const ldapLoginEnabled =
|
||||
!!process.env.LDAP_URL && !!process.env.LDAP_BIND_DN && !!process.env.LDAP_USER_SEARCH_BASE;
|
||||
try {
|
||||
|
@ -63,12 +74,14 @@ router.get('/', async function (req, res) {
|
|||
sharedLinksEnabled,
|
||||
publicSharedLinksEnabled,
|
||||
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
|
||||
instanceProjectId: instanceProject._id.toString(),
|
||||
};
|
||||
|
||||
if (typeof process.env.CUSTOM_FOOTER === 'string') {
|
||||
payload.customFooter = process.env.CUSTOM_FOOTER;
|
||||
}
|
||||
|
||||
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
||||
return res.status(200).send(payload);
|
||||
} catch (err) {
|
||||
logger.error('Error in startup config', err);
|
||||
|
|
|
@ -19,6 +19,8 @@ const assistants = require('./assistants');
|
|||
const files = require('./files');
|
||||
const staticRoute = require('./static');
|
||||
const share = require('./share');
|
||||
const categories = require('./categories');
|
||||
const roles = require('./roles');
|
||||
|
||||
module.exports = {
|
||||
search,
|
||||
|
@ -42,4 +44,6 @@ module.exports = {
|
|||
files,
|
||||
staticRoute,
|
||||
share,
|
||||
categories,
|
||||
roles,
|
||||
};
|
||||
|
|
|
@ -1,14 +1,218 @@
|
|||
const express = require('express');
|
||||
const { PermissionTypes, Permissions, SystemRoles } = require('librechat-data-provider');
|
||||
const {
|
||||
getPrompt,
|
||||
getPrompts,
|
||||
savePrompt,
|
||||
deletePrompt,
|
||||
getPromptGroup,
|
||||
getPromptGroups,
|
||||
updatePromptGroup,
|
||||
deletePromptGroup,
|
||||
createPromptGroup,
|
||||
// updatePromptLabels,
|
||||
makePromptProduction,
|
||||
} = require('~/models/Prompt');
|
||||
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
const { getPrompts } = require('../../models/Prompt');
|
||||
|
||||
const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]);
|
||||
const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [
|
||||
Permissions.USE,
|
||||
Permissions.CREATE,
|
||||
]);
|
||||
const checkGlobalPromptShare = generateCheckAccess(
|
||||
PermissionTypes.PROMPTS,
|
||||
[Permissions.USE, Permissions.CREATE],
|
||||
{
|
||||
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
|
||||
},
|
||||
);
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkPromptAccess);
|
||||
|
||||
/**
|
||||
* Route to get single prompt group by its ID
|
||||
* GET /groups/:groupId
|
||||
*/
|
||||
router.get('/groups/:groupId', async (req, res) => {
|
||||
let groupId = req.params.groupId;
|
||||
const author = req.user.id;
|
||||
|
||||
const query = {
|
||||
_id: groupId,
|
||||
$or: [{ projectIds: { $exists: true, $ne: [], $not: { $size: 0 } } }, { author }],
|
||||
};
|
||||
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
delete query.$or;
|
||||
}
|
||||
|
||||
try {
|
||||
const group = await getPromptGroup(query);
|
||||
|
||||
if (!group) {
|
||||
return res.status(404).send({ message: 'Prompt group not found' });
|
||||
}
|
||||
|
||||
res.status(200).send(group);
|
||||
} catch (error) {
|
||||
logger.error('Error getting prompt group', error);
|
||||
res.status(500).send({ message: 'Error getting prompt group' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Route to fetch paginated prompt groups with filters
|
||||
* GET /groups
|
||||
*/
|
||||
router.get('/groups', async (req, res) => {
|
||||
try {
|
||||
const filter = req.query;
|
||||
/* Note: The aggregation requires an ObjectId */
|
||||
filter.author = req.user._id;
|
||||
const groups = await getPromptGroups(req, filter);
|
||||
res.status(200).send(groups);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error getting prompt groups' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates or creates a prompt + promptGroup
|
||||
* @param {object} req
|
||||
* @param {TCreatePrompt} req.body
|
||||
* @param {Express.Response} res
|
||||
*/
|
||||
const createPrompt = async (req, res) => {
|
||||
try {
|
||||
const { prompt, group } = req.body;
|
||||
if (!prompt) {
|
||||
return res.status(400).send({ error: 'Prompt is required' });
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
prompt,
|
||||
group,
|
||||
author: req.user.id,
|
||||
authorName: req.user.name,
|
||||
};
|
||||
|
||||
/** @type {TCreatePromptResponse} */
|
||||
let result;
|
||||
if (group && group.name) {
|
||||
result = await createPromptGroup(saveData);
|
||||
} else {
|
||||
result = await savePrompt(saveData);
|
||||
}
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error saving prompt' });
|
||||
}
|
||||
};
|
||||
|
||||
router.post('/', createPrompt);
|
||||
|
||||
/**
|
||||
* Updates a prompt group
|
||||
* @param {object} req
|
||||
* @param {object} req.params - The request parameters
|
||||
* @param {string} req.params.groupId - The group ID
|
||||
* @param {TUpdatePromptGroupPayload} req.body - The request body
|
||||
* @param {Express.Response} res
|
||||
*/
|
||||
const patchPromptGroup = async (req, res) => {
|
||||
try {
|
||||
const { groupId } = req.params;
|
||||
const author = req.user.id;
|
||||
const filter = { _id: groupId, author };
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
delete filter.author;
|
||||
}
|
||||
const promptGroup = await updatePromptGroup(filter, req.body);
|
||||
res.status(200).send(promptGroup);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error updating prompt group' });
|
||||
}
|
||||
};
|
||||
|
||||
router.patch('/groups/:groupId', checkGlobalPromptShare, patchPromptGroup);
|
||||
|
||||
router.patch('/:promptId/tags/production', checkPromptCreate, async (req, res) => {
|
||||
try {
|
||||
const { promptId } = req.params;
|
||||
const result = await makePromptProduction(promptId);
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error updating prompt production' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:promptId', async (req, res) => {
|
||||
const { promptId } = req.params;
|
||||
const author = req.user.id;
|
||||
const query = { _id: promptId, author };
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
delete query.author;
|
||||
}
|
||||
const prompt = await getPrompt(query);
|
||||
res.status(200).send(prompt);
|
||||
});
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
let filter = {};
|
||||
// const { search } = req.body.arg;
|
||||
// if (!!search) {
|
||||
// filter = { conversationId };
|
||||
// }
|
||||
res.status(200).send(await getPrompts(filter));
|
||||
try {
|
||||
const author = req.user.id;
|
||||
const { groupId } = req.query;
|
||||
const query = { groupId, author };
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
delete query.author;
|
||||
}
|
||||
const prompts = await getPrompts(query);
|
||||
res.status(200).send(prompts);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error getting prompts' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes a prompt
|
||||
*
|
||||
* @param {Express.Request} req - The request object.
|
||||
* @param {TDeletePromptVariables} req.params - The request parameters
|
||||
* @param {import('mongoose').ObjectId} req.params.promptId - The prompt ID
|
||||
* @param {Express.Response} res - The response object.
|
||||
* @return {TDeletePromptResponse} A promise that resolves when the prompt is deleted.
|
||||
*/
|
||||
const deletePromptController = async (req, res) => {
|
||||
try {
|
||||
const { promptId } = req.params;
|
||||
const { groupId } = req.query;
|
||||
const author = req.user.id;
|
||||
const query = { promptId, groupId, author, role: req.user.role };
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
delete query.author;
|
||||
}
|
||||
const result = await deletePrompt(query);
|
||||
res.status(200).send(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).send({ error: 'Error deleting prompt' });
|
||||
}
|
||||
};
|
||||
|
||||
router.delete('/:promptId', checkPromptCreate, deletePromptController);
|
||||
|
||||
router.delete('/groups/:groupId', checkPromptCreate, async (req, res) => {
|
||||
const { groupId } = req.params;
|
||||
res.status(200).send(await deletePromptGroup(groupId));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
72
api/server/routes/roles.js
Normal file
72
api/server/routes/roles.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
const express = require('express');
|
||||
const {
|
||||
promptPermissionsSchema,
|
||||
PermissionTypes,
|
||||
roleDefaults,
|
||||
SystemRoles,
|
||||
} = require('librechat-data-provider');
|
||||
const { checkAdmin, requireJwtAuth } = require('~/server/middleware');
|
||||
const { updateRoleByName, getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
/**
|
||||
* GET /api/roles/:roleName
|
||||
* Get a specific role by name
|
||||
*/
|
||||
router.get('/:roleName', async (req, res) => {
|
||||
const { roleName: _r } = req.params;
|
||||
// TODO: TEMP, use a better parsing for roleName
|
||||
const roleName = _r.toUpperCase();
|
||||
|
||||
if (req.user.role !== SystemRoles.ADMIN && !roleDefaults[roleName]) {
|
||||
return res.status(403).send({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await getRoleByName(roleName, '-_id -__v');
|
||||
if (!role) {
|
||||
return res.status(404).send({ message: 'Role not found' });
|
||||
}
|
||||
|
||||
res.status(200).send(role);
|
||||
} catch (error) {
|
||||
return res.status(500).send({ message: 'Failed to retrieve role', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/roles/:roleName/prompts
|
||||
* Update prompt permissions for a specific role
|
||||
*/
|
||||
router.put('/:roleName/prompts', checkAdmin, async (req, res) => {
|
||||
const { roleName: _r } = req.params;
|
||||
// TODO: TEMP, use a better parsing for roleName
|
||||
const roleName = _r.toUpperCase();
|
||||
/** @type {TRole['PROMPTS']} */
|
||||
const updates = req.body;
|
||||
|
||||
try {
|
||||
const parsedUpdates = promptPermissionsSchema.partial().parse(updates);
|
||||
|
||||
const role = await getRoleByName(roleName);
|
||||
if (!role) {
|
||||
return res.status(404).send({ message: 'Role not found' });
|
||||
}
|
||||
|
||||
const mergedUpdates = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
...role[PermissionTypes.PROMPTS],
|
||||
...parsedUpdates,
|
||||
},
|
||||
};
|
||||
|
||||
const updatedRole = await updateRoleByName(roleName, mergedUpdates);
|
||||
res.status(200).send(updatedRole);
|
||||
} catch (error) {
|
||||
return res.status(400).send({ message: 'Invalid prompt permissions.', error: error.errors });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
|
@ -7,6 +7,7 @@ const handleRateLimits = require('./Config/handleRateLimits');
|
|||
const { loadDefaultInterface } = require('./start/interface');
|
||||
const { azureConfigSetup } = require('./start/azureOpenAI');
|
||||
const { loadAndFormatTools } = require('./ToolService');
|
||||
const { initializeRoles } = require('~/models/Role');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
/**
|
||||
|
@ -16,6 +17,7 @@ const paths = require('~/config/paths');
|
|||
* @param {Express.Application} app - The Express application object.
|
||||
*/
|
||||
const AppService = async (app) => {
|
||||
await initializeRoles();
|
||||
/** @type {TCustomConfig}*/
|
||||
const config = (await loadCustomConfig()) ?? {};
|
||||
const configDefaults = getConfigDefaults();
|
||||
|
|
|
@ -21,6 +21,9 @@ jest.mock('./Config/loadCustomConfig', () => {
|
|||
jest.mock('./Files/Firebase/initialize', () => ({
|
||||
initializeFirebase: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
}));
|
||||
jest.mock('./ToolService', () => ({
|
||||
loadAndFormatTools: jest.fn().mockReturnValue({
|
||||
ExampleTool: {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { errorsToString } = require('librechat-data-provider');
|
||||
const { SystemRoles, errorsToString } = require('librechat-data-provider');
|
||||
const {
|
||||
findUser,
|
||||
countUsers,
|
||||
|
@ -169,7 +169,7 @@ const registerUser = async (user) => {
|
|||
username,
|
||||
name,
|
||||
avatar: null,
|
||||
role: isFirstRegisteredUser ? 'ADMIN' : 'USER',
|
||||
role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER,
|
||||
password: bcrypt.hashSync(password, salt),
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { getUserById } = require('~/models');
|
||||
const { getUserById, updateUser } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
// JWT strategy
|
||||
|
@ -14,6 +15,10 @@ const jwtLogin = async () =>
|
|||
const user = await getUserById(payload?.id, '-password -__v');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
if (!user.role) {
|
||||
user.role = SystemRoles.USER;
|
||||
await updateUser(user.id, { role: user.role });
|
||||
}
|
||||
done(null, user);
|
||||
} else {
|
||||
logger.warn('[jwtLogin] JwtStrategy => no user found: ' + payload?.id);
|
||||
|
|
116
api/typedefs.js
116
api/typedefs.js
|
@ -248,6 +248,110 @@
|
|||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/** Prompts */
|
||||
/**
|
||||
* @exports TPrompt
|
||||
* @typedef {import('librechat-data-provider').TPrompt} TPrompt
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TPromptGroup
|
||||
* @typedef {import('librechat-data-provider').TPromptGroup} TPromptGroup
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TCreatePrompt
|
||||
* @typedef {import('librechat-data-provider').TCreatePrompt} TCreatePrompt
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TCreatePromptRecord
|
||||
* @typedef {import('librechat-data-provider').TCreatePromptRecord} TCreatePromptRecord
|
||||
* @memberof typedefs
|
||||
*/
|
||||
/**
|
||||
* @exports TCreatePromptResponse
|
||||
* @typedef {import('librechat-data-provider').TCreatePromptResponse} TCreatePromptResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
/**
|
||||
* @exports TUpdatePromptGroupResponse
|
||||
* @typedef {import('librechat-data-provider').TUpdatePromptGroupResponse} TUpdatePromptGroupResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TPromptGroupsWithFilterRequest
|
||||
* @typedef {import('librechat-data-provider').TPromptGroupsWithFilterRequest } TPromptGroupsWithFilterRequest
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports PromptGroupListResponse
|
||||
* @typedef {import('librechat-data-provider').PromptGroupListResponse } PromptGroupListResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TGetCategoriesResponse
|
||||
* @typedef {import('librechat-data-provider').TGetCategoriesResponse } TGetCategoriesResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TGetRandomPromptsResponse
|
||||
* @typedef {import('librechat-data-provider').TGetRandomPromptsResponse } TGetRandomPromptsResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TGetRandomPromptsRequest
|
||||
* @typedef {import('librechat-data-provider').TGetRandomPromptsRequest } TGetRandomPromptsRequest
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TUpdatePromptGroupPayload
|
||||
* @typedef {import('librechat-data-provider').TUpdatePromptGroupPayload } TUpdatePromptGroupPayload
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TDeletePromptVariables
|
||||
* @typedef {import('librechat-data-provider').TDeletePromptVariables } TDeletePromptVariables
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports TDeletePromptResponse
|
||||
* @typedef {import('librechat-data-provider').TDeletePromptResponse } TDeletePromptResponse
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/* Roles */
|
||||
|
||||
/**
|
||||
* @exports TRole
|
||||
* @typedef {import('librechat-data-provider').TRole } TRole
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports PermissionTypes
|
||||
* @typedef {import('librechat-data-provider').PermissionTypes } PermissionTypes
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Permissions
|
||||
* @typedef {import('librechat-data-provider').Permissions } Permissions
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/** Assistants */
|
||||
/**
|
||||
* @exports Assistant
|
||||
* @typedef {import('librechat-data-provider').Assistant} Assistant
|
||||
|
@ -500,6 +604,18 @@
|
|||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports MongoProject
|
||||
* @typedef {import('~/models/schema/projectSchema.js').MongoProject} MongoProject
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports MongoPromptGroup
|
||||
* @typedef {import('~/models/schema/promptSchema.js').MongoPromptGroup} MongoPromptGroup
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports uploadImageBuffer
|
||||
* @typedef {import('~/server/services/Files/process').uploadImageBuffer} uploadImageBuffer
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "0.7.3",
|
||||
"version": "0.7.4-rc1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
@ -66,7 +66,7 @@
|
|||
"image-blob-reduce": "^4.1.0",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.220.0",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"rc-input-number": "^7.4.2",
|
||||
"react": "^18.2.0",
|
||||
|
|
6
client/src/Providers/ChatFormContext.tsx
Normal file
6
client/src/Providers/ChatFormContext.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { createFormContext } from './CustomFormContext';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
|
||||
const { CustomFormProvider, useCustomFormContext } = createFormContext<ChatFormValues>();
|
||||
|
||||
export { CustomFormProvider as ChatFormProvider, useCustomFormContext as useChatFormContext };
|
56
client/src/Providers/CustomFormContext.tsx
Normal file
56
client/src/Providers/CustomFormContext.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import React, { createContext, PropsWithChildren, ReactElement, useContext, useMemo } from 'react';
|
||||
import type {
|
||||
Control,
|
||||
// FieldErrors,
|
||||
FieldValues,
|
||||
UseFormReset,
|
||||
UseFormRegister,
|
||||
UseFormGetValues,
|
||||
UseFormHandleSubmit,
|
||||
UseFormSetValue,
|
||||
} from 'react-hook-form';
|
||||
|
||||
interface FormContextValue<TFieldValues extends FieldValues> {
|
||||
register: UseFormRegister<TFieldValues>;
|
||||
control: Control<TFieldValues>;
|
||||
// errors: FieldErrors<TFieldValues>;
|
||||
getValues: UseFormGetValues<TFieldValues>;
|
||||
setValue: UseFormSetValue<TFieldValues>;
|
||||
handleSubmit: UseFormHandleSubmit<TFieldValues>;
|
||||
reset: UseFormReset<TFieldValues>;
|
||||
}
|
||||
|
||||
function createFormContext<TFieldValues extends FieldValues>() {
|
||||
const context = createContext<FormContextValue<TFieldValues> | undefined>(undefined);
|
||||
|
||||
const useCustomFormContext = (): FormContextValue<TFieldValues> => {
|
||||
const value = useContext(context);
|
||||
if (!value) {
|
||||
throw new Error('useCustomFormContext must be used within a CustomFormProvider');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const CustomFormProvider = ({
|
||||
register,
|
||||
control,
|
||||
setValue,
|
||||
// errors,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
reset,
|
||||
children,
|
||||
}: PropsWithChildren<FormContextValue<TFieldValues>>): ReactElement => {
|
||||
const value = useMemo(
|
||||
() => ({ register, control, getValues, setValue, handleSubmit, reset }),
|
||||
[register, control, setValue, getValues, handleSubmit, reset],
|
||||
);
|
||||
|
||||
return <context.Provider value={value}>{children}</context.Provider>;
|
||||
};
|
||||
|
||||
return { CustomFormProvider, useCustomFormContext };
|
||||
}
|
||||
|
||||
export type { FormContextValue };
|
||||
export { createFormContext };
|
7
client/src/Providers/DashboardContext.tsx
Normal file
7
client/src/Providers/DashboardContext.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
type TDashboardContext = {
|
||||
prevLocationPath: string;
|
||||
};
|
||||
|
||||
export const DashboardContext = createContext<TDashboardContext>({} as TDashboardContext);
|
||||
export const useDashboardContext = () => useContext(DashboardContext);
|
|
@ -5,5 +5,7 @@ export * from './ShareContext';
|
|||
export * from './ToastContext';
|
||||
export * from './SearchContext';
|
||||
export * from './FileMapContext';
|
||||
export * from './ChatFormContext';
|
||||
export * from './DashboardContext';
|
||||
export * from './AssistantsContext';
|
||||
export * from './AssistantsMapContext';
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { FileSources } from 'librechat-data-provider';
|
||||
import React from 'react';
|
||||
import { FileSources, SystemRoles } from 'librechat-data-provider';
|
||||
import type * as InputNumberPrimitive from 'rc-input-number';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type {
|
||||
TRole,
|
||||
TUser,
|
||||
Action,
|
||||
TPreset,
|
||||
|
@ -52,6 +54,8 @@ export type LastSelectedModels = Record<EModelEndpoint, string>;
|
|||
|
||||
export type LocalizeFunction = (phraseKey: string, ...values: string[]) => string;
|
||||
|
||||
export type ChatFormValues = { text: string };
|
||||
|
||||
export const mainTextareaId = 'prompt-textarea';
|
||||
export const globalAudioId = 'global-audio';
|
||||
|
||||
|
@ -75,7 +79,7 @@ export type IconMapProps = {
|
|||
export type NavLink = {
|
||||
title: string;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
icon: LucideIcon | React.FC;
|
||||
Component?: React.ComponentType;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'ghost';
|
||||
|
@ -325,6 +329,7 @@ export type TAuthContext = {
|
|||
login: (data: TLoginUser) => void;
|
||||
logout: () => void;
|
||||
setError: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
roles?: Record<string, TRole | null | undefined>;
|
||||
};
|
||||
|
||||
export type TUserContext = {
|
||||
|
@ -394,7 +399,6 @@ export interface SwitcherProps {
|
|||
endpointKeyProvided: boolean;
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
export type TLoginLayoutContext = {
|
||||
startupConfig: TStartupConfig | null;
|
||||
startupConfigError: unknown;
|
||||
|
@ -404,3 +408,19 @@ export type TLoginLayoutContext = {
|
|||
headerText: string;
|
||||
setHeaderText: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
export type TVectorStore = {
|
||||
_id: string;
|
||||
object: 'vector_store';
|
||||
created_at: string | Date;
|
||||
name: string;
|
||||
bytes?: number;
|
||||
file_counts?: {
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
cancelled: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type TThread = { id: string; createdAt: string };
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { ThemeSelector } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { BlinkAnimation } from './BlinkAnimation';
|
||||
import { TStartupConfig } from 'librechat-data-provider';
|
||||
import SocialLoginRender from './SocialLoginRender';
|
||||
import { ThemeSelector } from '~/components/ui';
|
||||
import Footer from './Footer';
|
||||
|
||||
const ErrorRender = ({ children }: { children: React.ReactNode }) => (
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
|
||||
import { ChatContext, useFileMapContext } from '~/Providers';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
import { ChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import { useChatHelpers, useSSE } from '~/hooks';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
@ -30,25 +32,37 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
});
|
||||
|
||||
const chatHelpers = useChatHelpers(index, conversationId);
|
||||
const methods = useForm<ChatFormValues>({
|
||||
defaultValues: { text: '' },
|
||||
});
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
<Presentation useSidePanel={true}>
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="opacity-0" />
|
||||
<ChatFormProvider
|
||||
reset={methods.reset}
|
||||
control={methods.control}
|
||||
setValue={methods.setValue}
|
||||
register={methods.register}
|
||||
getValues={methods.getValues}
|
||||
handleSubmit={methods.handleSubmit}
|
||||
>
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
<Presentation useSidePanel={true}>
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="opacity-0" />
|
||||
</div>
|
||||
) : messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing Header={<Header />} />
|
||||
)}
|
||||
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
<Footer />
|
||||
</div>
|
||||
) : messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing Header={<Header />} />
|
||||
)}
|
||||
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
<Footer />
|
||||
</div>
|
||||
</Presentation>
|
||||
</ChatContext.Provider>
|
||||
</Presentation>
|
||||
</ChatContext.Provider>
|
||||
</ChatFormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
||||
import { ListeningIcon, Spinner } from '~/components/svg';
|
||||
import { useLocalize, useSpeechToText } from '~/hooks';
|
||||
import { useChatFormContext } from '~/Providers';
|
||||
import { globalAudioId } from '~/common';
|
||||
|
||||
export default function AudioRecorder({
|
||||
|
@ -12,7 +12,7 @@ export default function AudioRecorder({
|
|||
disabled,
|
||||
}: {
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
methods: UseFormReturn<{ text: string }>;
|
||||
methods: ReturnType<typeof useChatFormContext>;
|
||||
ask: (data: { text: string }) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { memo, useRef, useMemo } from 'react';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { memo, useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAssistantsEndpoint,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { useChatContext, useAssistantsMapContext, useChatFormContext } from '~/Providers';
|
||||
import { useRequiresKey, useTextarea, useSubmitMessage } from '~/hooks';
|
||||
import { useAutoSave } from '~/hooks/Input/useAutoSave';
|
||||
import { useRequiresKey, useTextarea } from '~/hooks';
|
||||
import { TextareaAutosize } from '~/components/ui';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
|
@ -35,10 +34,6 @@ const ChatForm = ({ index = 0 }) => {
|
|||
);
|
||||
const { requiresKey } = useRequiresKey();
|
||||
|
||||
const methods = useForm<{ text: string }>({
|
||||
defaultValues: { text: '' },
|
||||
});
|
||||
|
||||
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
|
||||
useTextarea({
|
||||
textAreaRef,
|
||||
|
@ -47,7 +42,6 @@ const ChatForm = ({ index = 0 }) => {
|
|||
});
|
||||
|
||||
const {
|
||||
ask,
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
|
@ -56,28 +50,17 @@ const ChatForm = ({ index = 0 }) => {
|
|||
setFilesLoading,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
const methods = useChatFormContext();
|
||||
|
||||
const { clearDraft } = useAutoSave({
|
||||
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
|
||||
textAreaRef,
|
||||
setValue: methods.setValue,
|
||||
files,
|
||||
setFiles,
|
||||
});
|
||||
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const submitMessage = useCallback(
|
||||
(data?: { text: string }) => {
|
||||
if (!data) {
|
||||
return console.warn('No data provided to submitMessage');
|
||||
}
|
||||
ask({ text: data.text });
|
||||
methods.reset();
|
||||
clearDraft();
|
||||
},
|
||||
[ask, methods, clearDraft],
|
||||
);
|
||||
const { submitMessage } = useSubmitMessage({ clearDraft });
|
||||
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const endpoint = endpointType ?? _endpoint;
|
||||
|
|
|
@ -66,7 +66,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
<label
|
||||
htmlFor={`file-upload-${id}`}
|
||||
className={cn(
|
||||
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
|
||||
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
|
||||
statusColor,
|
||||
containerClassName,
|
||||
)}
|
||||
|
|
|
@ -7,7 +7,7 @@ const sourceToEndpoint = {
|
|||
[FileSources.azure]: EModelEndpoint.azureOpenAI,
|
||||
};
|
||||
const sourceToClassname = {
|
||||
[FileSources.openai]: 'bg-black/65',
|
||||
[FileSources.openai]: 'bg-white/75 dark:bg-black/65',
|
||||
[FileSources.azure]: 'azure-bg-color opacity-85',
|
||||
};
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
setRowSelection({});
|
||||
}}
|
||||
className="ml-1 gap-2 dark:hover:bg-gray-750/25 sm:ml-0"
|
||||
className="dark:hover:bg-gray-850/25 ml-1 gap-2 sm:ml-0"
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
|
@ -121,7 +121,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
{/* Filter Menu */}
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="z-[1001] dark:border-gray-700 dark:bg-gray-750"
|
||||
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
|
||||
>
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
|
|
@ -57,7 +57,7 @@ export function SortFilterHeader<TData, TValue>({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="z-[1001] dark:border-gray-700 dark:bg-gray-750"
|
||||
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => column.toggleSorting(false)}
|
||||
|
|
|
@ -78,7 +78,10 @@ const PresetItems: FC<{
|
|||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
|
||||
<Label
|
||||
htmlFor="preset-item-clear-all"
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{localize('com_endpoint_presets_clear_warning')}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
@ -21,7 +21,7 @@ export const ErrorMessage = ({
|
|||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-message mb-[0.625rem] mt-1 flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
|
||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
|
||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||
<div className="absolute">
|
||||
<p className="relative">
|
||||
|
|
15
client/src/components/Chat/PromptCard.tsx
Normal file
15
client/src/components/Chat/PromptCard.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
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 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>
|
||||
);
|
||||
}
|
62
client/src/components/Chat/PromptLanding.tsx
Normal file
62
client/src/components/Chat/PromptLanding.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import { TooltipProvider, Tooltip } from '~/components/ui';
|
||||
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
|
||||
import { getIconEndpoint, cn } from '~/utils';
|
||||
import Prompts from './Prompts';
|
||||
|
||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
let { endpoint = '' } = conversation ?? {};
|
||||
const { assistant_id = null } = conversation ?? {};
|
||||
|
||||
if (
|
||||
endpoint === EModelEndpoint.chatGPTBrowser ||
|
||||
endpoint === EModelEndpoint.azureOpenAI ||
|
||||
endpoint === EModelEndpoint.gptPlugins
|
||||
) {
|
||||
endpoint = EModelEndpoint.openAI;
|
||||
}
|
||||
|
||||
const iconURL = conversation?.iconURL;
|
||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
||||
|
||||
const isAssistant = isAssistantsEndpoint(endpoint);
|
||||
const assistant = isAssistant && assistantMap?.[endpoint]?.[assistant_id ?? ''];
|
||||
const assistantName = (assistant && assistant?.name) || '';
|
||||
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
|
||||
|
||||
const containerClassName =
|
||||
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 right-0">{Header && Header}</div>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className={cn('relative h-12 w-12', assistantName && avatar ? 'mb-0' : 'mb-3')}>
|
||||
<ConvoIcon
|
||||
conversation={conversation}
|
||||
assistantMap={assistantMap}
|
||||
endpointsConfig={endpointsConfig}
|
||||
containerClassName={containerClassName}
|
||||
context="landing"
|
||||
className="h-2/3 w-2/3"
|
||||
size={41}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-3/5">
|
||||
<Prompts />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
95
client/src/components/Chat/Prompts.tsx
Normal file
95
client/src/components/Chat/Prompts.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -168,7 +168,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
|||
className={cn(
|
||||
isActiveConvo || isPopoverActive
|
||||
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
|
||||
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-700',
|
||||
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-700',
|
||||
!isActiveConvo && !renaming ? 'peer-hover:bg-gray-200 dark:peer-hover:bg-gray-800' : '',
|
||||
)}
|
||||
title={title}
|
||||
|
@ -190,7 +190,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
|||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-60% dark:from-[#181818] dark:group-hover:from-gray-700" />
|
||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-60% dark:from-gray-850 dark:group-hover:from-gray-700" />
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -84,8 +84,8 @@ export default function DeleteButton({
|
|||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_conversation_confirm')} <strong>{title}</strong>
|
||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -8,23 +8,26 @@ const HoverToggle = ({
|
|||
isPopoverActive,
|
||||
setIsPopoverActive,
|
||||
className = 'absolute bottom-0 right-0 top-0',
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isActiveConvo: boolean;
|
||||
isPopoverActive: boolean;
|
||||
setIsPopoverActive: (isActive: boolean) => void;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}) => {
|
||||
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
|
||||
return (
|
||||
<ToggleContext.Provider value={{ isPopoverActive, setPopoverActive }}>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'peer items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',
|
||||
'peer items-center gap-1.5 rounded-r-lg from-gray-900 pl-2 pr-2 dark:text-white',
|
||||
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex',
|
||||
isActiveConvo
|
||||
? 'from-gray-50 from-85% to-transparent group-hover:bg-gradient-to-l group-hover:from-gray-200 dark:from-gray-800 dark:group-hover:from-gray-800'
|
||||
: 'z-50 from-gray-200 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-750 dark:from-gray-800 dark:hover:from-gray-800',
|
||||
: 'z-50 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-800 dark:hover:from-gray-800',
|
||||
isPopoverActive && !isActiveConvo ? 'from-gray-50 dark:from-gray-800' : '',
|
||||
className,
|
||||
)}
|
||||
|
|
|
@ -6,17 +6,19 @@ import { cn } from '~/utils';
|
|||
interface RenameButtonProps {
|
||||
renaming: boolean;
|
||||
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onRename?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
appendLabel?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RenameButton({
|
||||
renaming,
|
||||
renameHandler,
|
||||
onRename,
|
||||
appendLabel = false,
|
||||
renameHandler,
|
||||
className = '',
|
||||
disabled = false,
|
||||
appendLabel = false,
|
||||
}: RenameButtonProps): ReactElement {
|
||||
const localize = useLocalize();
|
||||
const handler = renaming ? onRename : renameHandler;
|
||||
|
@ -27,6 +29,7 @@ export default function RenameButton({
|
|||
'group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600',
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handler}
|
||||
>
|
||||
{renaming ? (
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useCreatePresetMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TEditPresetProps } from '~/common';
|
||||
import {
|
||||
cn,
|
||||
defaultTextPropsLabel,
|
||||
removeFocusOutlines,
|
||||
cleanupPreset,
|
||||
defaultTextProps,
|
||||
} from '~/utils/';
|
||||
import { cn, removeFocusOutlines, cleanupPreset, defaultTextProps } from '~/utils/';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { Dialog, Input, Label } from '~/components/ui/';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
@ -61,7 +55,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
|
|||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
|
||||
<Label htmlFor="dialog-preset-name" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_preset_name')}
|
||||
</Label>
|
||||
<Input
|
||||
|
|
20
client/src/components/Files/ActionButton.tsx
Normal file
20
client/src/components/Files/ActionButton.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import { CrossIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type ActionButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function ActionButton({ onClick }: ActionButtonProps) {
|
||||
return (
|
||||
<div className="w-32">
|
||||
<Button
|
||||
className="w-full rounded-md border border-black bg-white p-0 text-black hover:bg-black hover:text-white"
|
||||
onClick={onClick}
|
||||
>
|
||||
Action Button
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
17
client/src/components/Files/DeleteIconButton.tsx
Normal file
17
client/src/components/Files/DeleteIconButton.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { CrossIcon, NewTrashIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type DeleteIconButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function DeleteIconButton({ onClick }: DeleteIconButtonProps) {
|
||||
return (
|
||||
<div className="w-fit">
|
||||
<Button className="bg-red-400 p-3" onClick={onClick}>
|
||||
<NewTrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
39
client/src/components/Files/FileDashboardView.tsx
Normal file
39
client/src/components/Files/FileDashboardView.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import VectorStoreSidePanel from './VectorStore/VectorStoreSidePanel';
|
||||
import { Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button } from '../ui';
|
||||
|
||||
const FileDashboardView = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="bg-[#f9f9f9] p-0 lg:p-7">
|
||||
<div className="ml-3 mt-3 flex flex-row justify-between">
|
||||
{params?.vectorStoreId && (
|
||||
<Button
|
||||
className="block lg:hidden"
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
navigate('/d');
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-screen max-w-full flex-row divide-x bg-[#f9f9f9]">
|
||||
<div className={`w-full lg:w-1/3 ${params.vectorStoreId ? 'hidden lg:block' : ''}`}>
|
||||
<VectorStoreSidePanel />
|
||||
</div>
|
||||
<div className={`w-full lg:w-2/3 ${params.vectorStoreId ? '' : 'hidden lg:block'}`}>
|
||||
<div className="m-2 overflow-x-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDashboardView;
|
277
client/src/components/Files/FileList/DataTableFile.tsx
Normal file
277
client/src/components/Files/FileList/DataTableFile.tsx
Normal file
|
@ -0,0 +1,277 @@
|
|||
import * as React from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import { FileContext } from 'librechat-data-provider';
|
||||
import type { AugmentedColumnDef } from '~/common';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui';
|
||||
import { useDeleteFilesFromTable } from '~/hooks/Files';
|
||||
import { NewTrashIcon, Spinner } from '~/components/svg';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import ActionButton from '../ActionButton';
|
||||
import UploadFileButton from './UploadFileButton';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
const contextMap = {
|
||||
[FileContext.filename]: 'com_ui_name',
|
||||
[FileContext.updatedAt]: 'com_ui_date',
|
||||
[FileContext.source]: 'com_ui_storage',
|
||||
[FileContext.context]: 'com_ui_context',
|
||||
[FileContext.bytes]: 'com_ui_size',
|
||||
};
|
||||
|
||||
type Style = { width?: number | string; maxWidth?: number | string; minWidth?: number | string };
|
||||
|
||||
export default function DataTableFile<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2 flex flex-col items-start">
|
||||
<h2 className="text-lg">
|
||||
<strong>Files</strong>
|
||||
</h2>
|
||||
<div className="mt-3 flex w-full flex-col-reverse justify-between md:flex-row">
|
||||
<div className="mt-3 flex w-full flex-row justify-center gap-x-3 md:m-0 md:justify-start">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsDeleting(true);
|
||||
const filesToDelete = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
setRowSelection({});
|
||||
}}
|
||||
className="dark:hover:bg-gray-850/25 ml-1 gap-2 sm:ml-0"
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
<NewTrashIcon className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
{localize('com_ui_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full flex-row gap-x-3">
|
||||
{' '}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="ml-auto">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
|
||||
>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{localize(contextMap[column.id])}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
placeholder={localize('com_files_filter')}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm dark:border-gray-500"
|
||||
/>
|
||||
<UploadFileButton
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-3 max-h-[25rem] min-h-0 overflow-y-auto rounded-md border border-black/10 pb-4 dark:border-white/10 sm:min-h-[28rem]">
|
||||
<Table className="w-full min-w-[600px] border-separate border-spacing-0">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const style: Style = { maxWidth: '32px', minWidth: '125px' };
|
||||
if (header.id === 'filename') {
|
||||
style.maxWidth = '25%';
|
||||
style.width = '25%';
|
||||
style.minWidth = '150px';
|
||||
}
|
||||
if (header.id === 'icon') {
|
||||
style.width = '25px';
|
||||
style.maxWidth = '25px';
|
||||
style.minWidth = '35px';
|
||||
}
|
||||
if (header.id === 'vectorStores') {
|
||||
style.maxWidth = '50%';
|
||||
style.width = '50%';
|
||||
style.minWidth = '300px';
|
||||
}
|
||||
|
||||
if (index === 0 && header.id === 'select') {
|
||||
style.width = '25px';
|
||||
style.maxWidth = '25px';
|
||||
style.minWidth = '35px';
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="align-start sticky top-0 rounded-t border-b border-black/10 bg-white px-2 py-1 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-700 dark:text-gray-100 sm:px-4 sm:py-2"
|
||||
style={style}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className="border-b border-black/10 text-left text-gray-600 dark:border-white/10 dark:text-gray-300 [tr:last-child_&]:border-b-0"
|
||||
>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const maxWidth =
|
||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ??
|
||||
'auto';
|
||||
|
||||
const style: Style = {};
|
||||
if (cell.column.id === 'filename') {
|
||||
style.maxWidth = maxWidth;
|
||||
} else if (index === 0) {
|
||||
style.maxWidth = '20px';
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="align-start overflow-x-auto px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm [tr[data-disabled=true]_&]:opacity-50"
|
||||
style={style}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{localize('com_files_no_results')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="ml-4 mr-4 mt-4 flex h-auto items-center justify-end space-x-2 py-4 sm:ml-0 sm:mr-0 sm:h-0">
|
||||
<div className="text-muted-foreground ml-2 flex-1 text-sm">
|
||||
{localize(
|
||||
'com_files_number_selected',
|
||||
`${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
`${table.getFilteredRowModel().rows.length}`,
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="dark:border-gray-500 dark:hover:bg-gray-600"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<Button
|
||||
className="dark:border-gray-500 dark:hover:bg-gray-600"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import DataTableFile from './DataTableFile';
|
||||
import { TVectorStore } from '~/common';
|
||||
import { files } from '../../Chat/Input/Files/Table';
|
||||
import { fileTableColumns } from './../FileList/FileTableColumns';
|
||||
|
||||
const vectorStoresAttached: TVectorStore[] = [
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
{
|
||||
name: 'vector 1 vector 1',
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
_id: 'id',
|
||||
object: 'vector_store',
|
||||
},
|
||||
];
|
||||
|
||||
files.forEach((file) => {
|
||||
file['vectorsAttached'] = vectorStoresAttached;
|
||||
});
|
||||
|
||||
export default function DataTableFilePreview() {
|
||||
return (
|
||||
<div>
|
||||
<DataTableFile columns={fileTableColumns} data={files} />
|
||||
<div className="mt-5 sm:mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function EmptyFilePreview() {
|
||||
return (
|
||||
<div className="h-full w-full content-center text-center font-bold">
|
||||
Select a file to view details.
|
||||
</div>
|
||||
);
|
||||
}
|
26
client/src/components/Files/FileList/FileList.tsx
Normal file
26
client/src/components/Files/FileList/FileList.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import React from 'react';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileListItem2 from './FileListItem2';
|
||||
|
||||
type FileListProps = {
|
||||
files: TFile[];
|
||||
deleteFile: (id: string | undefined) => void;
|
||||
attachedVectorStores: { name: string }[];
|
||||
};
|
||||
|
||||
export default function FileList({ files, deleteFile, attachedVectorStores }: FileListProps) {
|
||||
return (
|
||||
<div className="h-[85vh] overflow-y-auto">
|
||||
{files.map((file) => (
|
||||
// <FileListItem key={file._id} file={file} deleteFile={deleteFile} width="100%" />
|
||||
<FileListItem2
|
||||
key={file._id}
|
||||
file={file}
|
||||
deleteFile={deleteFile}
|
||||
attachedVectorStores={attachedVectorStores}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
33
client/src/components/Files/FileList/FileListItem.tsx
Normal file
33
client/src/components/Files/FileList/FileListItem.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import React from 'react';
|
||||
import { NewTrashIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type FileListItemProps = {
|
||||
file: TFile;
|
||||
deleteFile: (id: string | undefined) => void;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export default function FileListItem({ file, deleteFile, width = '400px' }: FileListItemProps) {
|
||||
return (
|
||||
<div className="w-100 my-3 mr-2 flex cursor-pointer flex-row rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200">
|
||||
<div className="flex w-1/2 flex-col justify-around align-middle">
|
||||
<strong>{file.filename}</strong>
|
||||
<p className="text-sm text-gray-500">{file.object}</p>
|
||||
</div>
|
||||
<div className="w-2/6 text-gray-500">
|
||||
<p>({file.bytes / 1000}KB)</p>
|
||||
<p className="text-sm">{file.createdAt?.toString()}</p>
|
||||
</div>
|
||||
<div className="flex w-1/6 justify-around">
|
||||
<Button
|
||||
className="my-0 ml-3 bg-transparent p-0 text-[#666666] hover:bg-slate-200"
|
||||
onClick={() => deleteFile(file._id)}
|
||||
>
|
||||
<NewTrashIcon className="m-0 p-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
76
client/src/components/Files/FileList/FileListItem2.tsx
Normal file
76
client/src/components/Files/FileList/FileListItem2.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import { FileIcon, PlusIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DotsIcon, NewTrashIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type FileListItemProps = {
|
||||
file: TFile;
|
||||
deleteFile: (id: string | undefined) => void;
|
||||
attachedVectorStores: { name: string }[];
|
||||
};
|
||||
|
||||
export default function FileListItem2({
|
||||
file,
|
||||
deleteFile,
|
||||
attachedVectorStores,
|
||||
}: FileListItemProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
navigate('file_id_abcdef');
|
||||
}}
|
||||
className="w-100 mt-2 flex h-fit cursor-pointer flex-row rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200"
|
||||
>
|
||||
<div className="flex w-10/12 flex-col justify-around md:flex-row">
|
||||
<div className="flex w-2/5 flex-row">
|
||||
<div className="w-1/4 content-center">
|
||||
<FileIcon className="m-0 size-5 p-0" />
|
||||
</div>
|
||||
<div className="w-3/4 content-center">{file.filename}</div>
|
||||
</div>
|
||||
<div className="flex w-fit flex-row flex-wrap text-gray-500 md:w-3/5">
|
||||
{attachedVectorStores.map((vectorStore, index) => {
|
||||
if (index === 4) {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-2 mt-1 flex flex-row items-center rounded-full bg-[#f5f5f5] px-2 text-xs"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
|
||||
{attachedVectorStores.length - index} more
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (index > 4) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
|
||||
>
|
||||
{vectorStore.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-0 flex w-2/12 flex-col items-center justify-evenly sm:mr-4 md:flex-row">
|
||||
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">
|
||||
<DotsIcon className="text-grey-100" />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-min bg-transparent text-[#666666] hover:bg-slate-200"
|
||||
onClick={() => deleteFile(file._id)}
|
||||
>
|
||||
<NewTrashIcon className="" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
180
client/src/components/Files/FileList/FilePreview.tsx
Normal file
180
client/src/components/Files/FileList/FilePreview.tsx
Normal file
|
@ -0,0 +1,180 @@
|
|||
import { TFile } from 'librechat-data-provider/dist/types';
|
||||
import React, { useState } from 'react';
|
||||
import { TThread, TVectorStore } from '~/common';
|
||||
import { CheckMark, NewTrashIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
import DeleteIconButton from '../DeleteIconButton';
|
||||
import VectorStoreButton from '../VectorStore/VectorStoreButton';
|
||||
import { CircleIcon, Clock3Icon, InfoIcon } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
const tempFile: TFile = {
|
||||
filename: 'File1.jpg',
|
||||
object: 'file',
|
||||
bytes: 10000,
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
_id: '1',
|
||||
type: 'image',
|
||||
usage: 12,
|
||||
user: 'abc',
|
||||
file_id: 'file_id',
|
||||
embedded: true,
|
||||
filepath: 'filepath',
|
||||
};
|
||||
|
||||
const tempThreads: TThread[] = [
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
|
||||
];
|
||||
|
||||
const tempVectorStoresAttached: TVectorStore[] = [
|
||||
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
|
||||
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
|
||||
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
|
||||
];
|
||||
|
||||
export default function FilePreview() {
|
||||
const [file, setFile] = useState(tempFile);
|
||||
const [threads, setThreads] = useState(tempThreads);
|
||||
const [vectorStoresAttached, setVectorStoresAttached] = useState(tempVectorStoresAttached);
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div className="m-3 bg-white p-2 sm:p-4 md:p-6 lg:p-10">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col">
|
||||
<b className="hidden text-sm md:text-base lg:block lg:text-lg">FILE</b>
|
||||
<b className="text-center text-xl md:text-2xl lg:text-left lg:text-3xl">
|
||||
{file.filename}
|
||||
</b>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row gap-x-3 md:mt-0">
|
||||
<div>
|
||||
<DeleteIconButton
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<VectorStoreButton
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col">
|
||||
<div className="flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
|
||||
<InfoIcon className="size-4 text-gray-500" />
|
||||
File ID
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file._id}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
|
||||
<CircleIcon className="m-0 size-4 p-0 text-gray-500" />
|
||||
Status
|
||||
</span>
|
||||
<div className="w-1/2 sm:w-3/4 md:w-3/5">
|
||||
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
|
||||
<CheckMark className="m-0 p-0" />
|
||||
<div>{file.object}</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
|
||||
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
|
||||
Purpose
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file.message}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
|
||||
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
|
||||
Size
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file.bytes}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
|
||||
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
|
||||
Created At
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">
|
||||
{file.createdAt?.toString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col">
|
||||
<div>
|
||||
<b className="text-sm md:text-base lg:text-lg">Attached To</b>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y">
|
||||
<div className="mt-2 flex flex-row">
|
||||
<div className="w-2/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-2/3">
|
||||
Vector Stores
|
||||
</div>
|
||||
<div className="w-3/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-1/3">Uploaded</div>
|
||||
</div>
|
||||
<div>
|
||||
{vectorStoresAttached.map((vectors, index) => (
|
||||
<div key={index} className="mt-2 flex flex-row">
|
||||
<div className="ml-4 w-2/5 content-center md:w-1/2 xl:w-2/3">{vectors.name}</div>
|
||||
<div className="flex w-3/5 flex-row md:w-1/2 xl:w-1/3">
|
||||
<div className="content-center text-nowrap">{vectors.created_at.toString()}</div>
|
||||
<Button
|
||||
className="m-0 ml-3 h-full bg-transparent p-0 text-[#666666] hover:bg-slate-200"
|
||||
onClick={() => {
|
||||
console.log('Remove from vector store');
|
||||
}}
|
||||
variant={'ghost'}
|
||||
>
|
||||
<NewTrashIcon className="m-0 p-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col">
|
||||
<div className="flex flex-col divide-y">
|
||||
<div className="flex flex-row">
|
||||
<div className="w-2/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-2/3">Threads</div>
|
||||
<div className="w-3/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-1/3">Uploaded</div>
|
||||
</div>
|
||||
<div>
|
||||
{threads.map((thread, index) => (
|
||||
<div key={index} className="mt-2 flex flex-row">
|
||||
<div className="ml-4 w-2/5 content-center md:w-1/2 xl:w-2/3">ID: {thread.id}</div>
|
||||
<div className="flex w-3/5 flex-row md:w-1/2 xl:w-1/3">
|
||||
<div className="content-center text-nowrap">{thread.createdAt}</div>
|
||||
<Button
|
||||
className="m-0 ml-3 h-full bg-transparent p-0 text-[#666666] hover:bg-slate-200"
|
||||
onClick={() => {
|
||||
console.log('Remove from thread');
|
||||
}}
|
||||
>
|
||||
<NewTrashIcon className="m-0 p-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
187
client/src/components/Files/FileList/FileSidePanel.tsx
Normal file
187
client/src/components/Files/FileList/FileSidePanel.tsx
Normal file
|
@ -0,0 +1,187 @@
|
|||
import React from 'react';
|
||||
import FileList from './FileList';
|
||||
import { TFile } from 'librechat-data-provider/dist/types';
|
||||
import FilesSectionSelector from '../FilesSectionSelector';
|
||||
import { Button, Input } from '~/components/ui';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import UploadFileButton from './UploadFileButton';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fakeFiles = [
|
||||
{
|
||||
filename: 'File1.jpg',
|
||||
object: 'Description 1',
|
||||
bytes: 10000,
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
_id: '1',
|
||||
},
|
||||
{
|
||||
filename: 'File2.jpg',
|
||||
object: 'Description 2',
|
||||
bytes: 15000,
|
||||
createdAt: '2022-01-02T15:30:00',
|
||||
_id: '2',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
filename: 'File3.jpg',
|
||||
object: 'Description 3',
|
||||
bytes: 20000,
|
||||
createdAt: '2022-01-03T09:45:00',
|
||||
_id: '3',
|
||||
},
|
||||
];
|
||||
|
||||
const attachedVectorStores = [
|
||||
{ name: 'VectorStore1' },
|
||||
{ name: 'VectorStore2' },
|
||||
{ name: 'VectorStore3' },
|
||||
{ name: 'VectorStore3' },
|
||||
{ name: 'VectorStore3' },
|
||||
{ name: 'VectorStore3' },
|
||||
{ name: 'VectorStore3' },
|
||||
{ name: 'VectorStore3' },
|
||||
{ name: 'VectorStore3' },
|
||||
];
|
||||
|
||||
export default function FileSidePanel() {
|
||||
const localize = useLocalize();
|
||||
const deleteFile = (id: string | undefined) => {
|
||||
// Define delete functionality here
|
||||
console.log(`Deleting File with id: ${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-30">
|
||||
<h2 className="m-3 text-lg">
|
||||
<strong>Files</strong>
|
||||
</h2>
|
||||
<div className="m-3 mt-2 flex w-full flex-row justify-between gap-x-2 lg:m-0">
|
||||
<div className="flex w-2/3 flex-row">
|
||||
<Button variant="ghost" className="m-0 mr-2 p-0">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
placeholder={localize('com_files_filter')}
|
||||
value={''}
|
||||
onChange={() => {
|
||||
console.log('changed');
|
||||
}}
|
||||
className="max-w-sm dark:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<UploadFileButton
|
||||
onClick={() => {
|
||||
console.log('Upload');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<FileList
|
||||
files={fakeFiles as TFile[]}
|
||||
deleteFile={deleteFile}
|
||||
attachedVectorStores={attachedVectorStores}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
123
client/src/components/Files/FileList/FileTableColumns.tsx
Normal file
123
client/src/components/Files/FileList/FileTableColumns.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import { CrossIcon, DotsIcon } from '~/components/svg';
|
||||
import { Button, Checkbox } from '~/components/ui';
|
||||
import { formatDate, getFileType } from '~/utils';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import FileIcon from '~/components/svg/Files/FileIcon';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
|
||||
export const fileTableColumns: ColumnDef<TFile>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="flex"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="flex"
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
size: '50px',
|
||||
},
|
||||
accessorKey: 'icon',
|
||||
header: () => {
|
||||
return 'Icon';
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const file = row.original;
|
||||
return <FileIcon file={file} fileType={getFileType(file.type)} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
size: '150px',
|
||||
},
|
||||
accessorKey: 'filename',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return <>{localize('com_ui_name')}</>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const file = row.original;
|
||||
return <span className="self-center truncate">{file.filename}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'vectorStores',
|
||||
header: () => {
|
||||
return 'Vector Stores';
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { vectorsAttached: attachedVectorStores } = row.original;
|
||||
return (
|
||||
<>
|
||||
{attachedVectorStores.map((vectorStore, index) => {
|
||||
if (index === 4)
|
||||
{return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-2 mt-2 flex w-fit flex-row items-center rounded-full bg-[#f5f5f5] px-2 text-gray-500"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
|
||||
{attachedVectorStores.length - index} more
|
||||
</span>
|
||||
);}
|
||||
if (index > 4) {return null;}
|
||||
return (
|
||||
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
|
||||
{vectorStore.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updatedAt',
|
||||
header: () => {
|
||||
const localize = useLocalize();
|
||||
return 'Modified';
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.updatedAt),
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => {
|
||||
return 'Actions';
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">
|
||||
<DotsIcon className="text-grey-100 m-0 size-5 p-0" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
18
client/src/components/Files/FileList/UploadFileButton.tsx
Normal file
18
client/src/components/Files/FileList/UploadFileButton.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { PlusIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type UploadFileProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function UploadFileButton({ onClick }: UploadFileProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Button className="w-full bg-black px-3 text-white" onClick={onClick}>
|
||||
<PlusIcon className="h-4 w-4 font-bold" />
|
||||
<span className="text-nowrap">Upload New File</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
88
client/src/components/Files/FileList/UploadFileModal.tsx
Normal file
88
client/src/components/Files/FileList/UploadFileModal.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import React, { useState, ChangeEvent } from 'react';
|
||||
import AttachFile from '~/components/Chat/Input/Files/AttachFile';
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const UploadFileModal = ({ open, onOpenChange }) => {
|
||||
const localize = useLocalize();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const selectedFile = e.target.files[0];
|
||||
setFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'w-11/12 overflow-x-auto p-3 shadow-2xl dark:bg-gray-700 dark:text-white lg:w-2/3 xl:w-2/5',
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
Upoad a File
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex w-full flex-col p-0 sm:p-6 sm:pb-0 sm:pt-4">
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="hidden w-1/5 sm:block">
|
||||
<img />
|
||||
</div>
|
||||
<div className="flex w-full flex-col text-center sm:w-4/5 sm:text-left">
|
||||
<div className="italic">Please upload square file, size less than 100KB</div>
|
||||
<div className="mt-4 flex w-full flex-row items-center bg-[#f9f9f9] p-2">
|
||||
<div className="w-1/2 sm:w-1/3">
|
||||
<Button>Choose File</Button>
|
||||
</div>
|
||||
<div className="w-1/2 sm:w-1/3"> No File Chosen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex w-full flex-col">
|
||||
<label htmlFor="name">Name</label>
|
||||
<label className="hidden text-[#808080] sm:block">The name of the uploaded file</label>
|
||||
<Input type="text" id="name" name="name" placeholder="Name" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex w-full flex-col">
|
||||
<label htmlFor="purpose">Purpose</label>
|
||||
<label className="hidden text-[#808080] sm:block">
|
||||
The purpose of the uploaded file
|
||||
</label>
|
||||
<Input type="text" id="purpose" name="purpose" placeholder="Purpose" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex w-full flex-row justify-between">
|
||||
<div className="hidden w-1/3 sm:block">
|
||||
<span className="font-bold">Learn about file purpose</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-row justify-evenly sm:w-1/3">
|
||||
<Button
|
||||
className="mr-3 w-full rounded-md border border-black bg-white p-0 text-black hover:bg-white"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full rounded-md border border-black bg-black p-0 text-white"
|
||||
onClick={() => {
|
||||
console.log('upload file');
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadFileModal;
|
45
client/src/components/Files/FilesListView.tsx
Normal file
45
client/src/components/Files/FilesListView.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React from 'react';
|
||||
import FileSidePanel from './FileList/FileSidePanel';
|
||||
import { Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||
import FilesSectionSelector from './FilesSectionSelector';
|
||||
import { Button } from '../ui';
|
||||
|
||||
export default function FilesListView() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="bg-[#f9f9f9] p-0 lg:p-7">
|
||||
<div className="m-4 flex w-full flex-row justify-between md:m-2">
|
||||
<FilesSectionSelector />
|
||||
{params?.fileId && (
|
||||
<Button
|
||||
className="block lg:hidden"
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
navigate('/d/files');
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-row divide-x">
|
||||
<div
|
||||
className={`mr-2 w-full xl:w-1/3 ${
|
||||
params.fileId ? 'hidden w-1/2 lg:block lg:w-1/2' : 'md:w-full'
|
||||
}`}
|
||||
>
|
||||
<FileSidePanel />
|
||||
</div>
|
||||
<div
|
||||
className={`h-[85vh] w-full overflow-y-auto xl:w-2/3 ${
|
||||
params.fileId ? 'lg:w-1/2' : 'hidden md:w-1/2 lg:block'
|
||||
}`}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
48
client/src/components/Files/FilesSectionSelector.tsx
Normal file
48
client/src/components/Files/FilesSectionSelector.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from '../ui';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function FilesSectionSelector() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
let selectedPage = '/vector-stores';
|
||||
|
||||
if (location.pathname.includes('vector-stores')) {
|
||||
selectedPage = '/vector-stores';
|
||||
}
|
||||
if (location.pathname.includes('files')) {
|
||||
selectedPage = '/files';
|
||||
}
|
||||
|
||||
const darkButton = { backgroundColor: 'black', color: 'white' };
|
||||
const lightButton = { backgroundColor: '#f9f9f9', color: 'black' };
|
||||
|
||||
return (
|
||||
<div className="flex h-12 w-52 flex-row justify-center rounded border bg-white p-1">
|
||||
<div className="flex w-2/3 items-center pr-1">
|
||||
<Button
|
||||
className="w-full rounded rounded-lg border"
|
||||
style={selectedPage === '/vector-stores' ? darkButton : lightButton}
|
||||
onClick={() => {
|
||||
selectedPage = '/vector-stores';
|
||||
navigate('/d/vector-stores');
|
||||
}}
|
||||
>
|
||||
Vector Stores
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-1/3 items-center">
|
||||
<Button
|
||||
className="w-full rounded rounded-lg border"
|
||||
style={selectedPage === '/files' ? darkButton : lightButton}
|
||||
onClick={() => {
|
||||
selectedPage = '/files';
|
||||
navigate('/d/files');
|
||||
}}
|
||||
>
|
||||
Files
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function EmptyVectorStorePreview() {
|
||||
return (
|
||||
<div className="h-full w-full content-center text-center font-bold">
|
||||
Select a vector store to view details.
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { PlusIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type VectorStoreButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export default function VectorStoreButton({ onClick }: VectorStoreButtonProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Button className="w-full bg-black p-0 text-white" onClick={onClick}>
|
||||
<PlusIcon className="h-4 w-4 font-bold" />
|
||||
<span className="text-nowrap">Add Store</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const VectorStoreFilter = () => {
|
||||
return <div>VectorStoreFilter</div>;
|
||||
};
|
||||
|
||||
export default VectorStoreFilter;
|
22
client/src/components/Files/VectorStore/VectorStoreList.tsx
Normal file
22
client/src/components/Files/VectorStore/VectorStoreList.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import VectorStoreListItem from './VectorStoreListItem';
|
||||
import { TVectorStore } from '~/common';
|
||||
|
||||
type VectorStoreListProps = {
|
||||
vectorStores: TVectorStore[];
|
||||
deleteVectorStore: (id: string | undefined) => void;
|
||||
};
|
||||
|
||||
export default function VectorStoreList({ vectorStores, deleteVectorStore }: VectorStoreListProps) {
|
||||
return (
|
||||
<div>
|
||||
{vectorStores.map((vectorStore, index) => (
|
||||
<VectorStoreListItem
|
||||
key={index}
|
||||
vectorStore={vectorStore}
|
||||
deleteVectorStore={deleteVectorStore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TVectorStore } from '~/common';
|
||||
import { DotsIcon, NewTrashIcon, TrashIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type VectorStoreListItemProps = {
|
||||
vectorStore: TVectorStore;
|
||||
deleteVectorStore: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function VectorStoreListItem({
|
||||
vectorStore,
|
||||
deleteVectorStore,
|
||||
}: VectorStoreListItemProps) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
navigate('vs_id_abcdef');
|
||||
}}
|
||||
className="w-100 mt-2 flex cursor-pointer flex-row justify-around rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200"
|
||||
>
|
||||
<div className="flex w-1/2 flex-col justify-around align-middle">
|
||||
<strong>{vectorStore.name}</strong>
|
||||
<p className="text-sm text-gray-500">{vectorStore.object}</p>
|
||||
</div>
|
||||
<div className="w-2/6 text-gray-500">
|
||||
<p>
|
||||
{vectorStore.file_counts.total} Files ({vectorStore.bytes / 1000}KB)
|
||||
</p>
|
||||
<p className="text-sm">{vectorStore.created_at.toString()}</p>
|
||||
</div>
|
||||
<div className="flex w-1/6 flex-col justify-around sm:flex-row">
|
||||
<Button className="m-0 w-full content-center bg-transparent p-0 text-gray-500 hover:bg-slate-200 sm:w-min">
|
||||
<DotsIcon className="text-grey-100 m-0 p-0" />
|
||||
</Button>
|
||||
<Button
|
||||
className="m-0 w-full bg-transparent p-0 text-[#666666] hover:bg-slate-200 sm:w-fit"
|
||||
onClick={() => deleteVectorStore(vectorStore._id)}
|
||||
>
|
||||
<NewTrashIcon className="m-0 p-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
244
client/src/components/Files/VectorStore/VectorStorePreview.tsx
Normal file
244
client/src/components/Files/VectorStore/VectorStorePreview.tsx
Normal file
|
@ -0,0 +1,244 @@
|
|||
import React, { useState } from 'react';
|
||||
import DeleteIconButton from '../DeleteIconButton';
|
||||
import { Button } from '~/components/ui';
|
||||
import { NewTrashIcon } from '~/components/svg';
|
||||
import { TFile } from 'librechat-data-provider/dist/types';
|
||||
import UploadFileButton from '../FileList/UploadFileButton';
|
||||
import UploadFileModal from '../FileList/UploadFileModal';
|
||||
import { BarChart4Icon, Clock3, FileClock, FileIcon, InfoIcon, PlusIcon } from 'lucide-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
const tempVectorStore = {
|
||||
_id: 'vs_NeHK4JidLKJ2qo23dKLLK',
|
||||
name: 'Vector Store 1',
|
||||
usageThisMonth: '1,000,000',
|
||||
bytes: 1000000,
|
||||
lastActive: '2022-01-01T10:00:00',
|
||||
expirationPolicy: 'Never',
|
||||
expires: 'Never',
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
};
|
||||
const tempFilesAttached: TFile[] = [
|
||||
{
|
||||
filename: 'File1.jpg',
|
||||
object: 'file',
|
||||
bytes: 10000,
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
_id: '1',
|
||||
type: 'image',
|
||||
usage: 12,
|
||||
user: 'abc',
|
||||
file_id: 'file_id',
|
||||
embedded: true,
|
||||
filepath: 'filepath',
|
||||
},
|
||||
{
|
||||
filename: 'File1.jpg',
|
||||
object: 'file',
|
||||
bytes: 10000,
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
_id: '1',
|
||||
type: 'image',
|
||||
usage: 12,
|
||||
user: 'abc',
|
||||
file_id: 'file_id',
|
||||
embedded: true,
|
||||
filepath: 'filepath',
|
||||
},
|
||||
{
|
||||
filename: 'File1.jpg',
|
||||
object: 'file',
|
||||
bytes: 10000,
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
_id: '1',
|
||||
type: 'image',
|
||||
usage: 12,
|
||||
user: 'abc',
|
||||
file_id: 'file_id',
|
||||
embedded: true,
|
||||
filepath: 'filepath',
|
||||
},
|
||||
{
|
||||
filename: 'File1.jpg',
|
||||
object: 'file',
|
||||
bytes: 10000,
|
||||
createdAt: '2022-01-01T10:00:00',
|
||||
_id: '1',
|
||||
type: 'image',
|
||||
usage: 12,
|
||||
user: 'abc',
|
||||
file_id: 'file_id',
|
||||
embedded: true,
|
||||
filepath: 'filepath',
|
||||
},
|
||||
];
|
||||
const tempAssistants = [
|
||||
{
|
||||
id: 'Lorum Ipsum',
|
||||
resource: 'Lorum Ipsum',
|
||||
},
|
||||
{
|
||||
id: 'Lorum Ipsum',
|
||||
resource: 'Lorum Ipsum',
|
||||
},
|
||||
{
|
||||
id: 'Lorum Ipsum',
|
||||
resource: 'Lorum Ipsum',
|
||||
},
|
||||
{
|
||||
id: 'Lorum Ipsum',
|
||||
resource: 'Lorum Ipsum',
|
||||
},
|
||||
];
|
||||
|
||||
export default function VectorStorePreview() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [vectorStore, setVectorStore] = useState(tempVectorStore);
|
||||
const [filesAttached, setFilesAttached] = useState(tempFilesAttached);
|
||||
const [assistants, setAssistants] = useState(tempAssistants);
|
||||
const params = useParams();
|
||||
|
||||
return (
|
||||
<div className="m-3 ml-1 mr-7 bg-white p-2 sm:p-4 md:p-6 lg:p-10">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col">
|
||||
<b className="hidden text-base md:text-lg lg:block lg:text-xl">VECTOR STORE</b>
|
||||
<b className="text-center text-xl md:text-2xl lg:text-left lg:text-3xl">
|
||||
{vectorStore.name}
|
||||
</b>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row gap-x-3 md:mt-0">
|
||||
<div>
|
||||
<DeleteIconButton
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<UploadFileButton
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col">
|
||||
<div className="flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
ID
|
||||
</span>
|
||||
<span className="w-1/2 break-words text-gray-500 md:w-3/5">{vectorStore._id}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<BarChart4Icon className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
Usage this month
|
||||
</span>
|
||||
<div className="w-1/2 md:w-3/5">
|
||||
<p className="text-gray-500">
|
||||
<span className="text-[#91c561]">0 KB hours</span>
|
||||
Free until end of 2024
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
Size
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.bytes} bytes</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<Clock3 className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
Last active
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.lastActive}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
Expiration policy
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.expirationPolicy}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<FileClock className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
Expires
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.expires}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-row">
|
||||
<span className="flex w-1/2 flex-row items-center md:w-2/5">
|
||||
<Clock3 className="text-base text-gray-500 md:text-lg lg:text-xl" />
|
||||
Created At
|
||||
</span>
|
||||
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.createdAt?.toString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col">
|
||||
<div>
|
||||
<b className="text-base md:text-lg lg:text-xl">Files attached</b>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y">
|
||||
<div className="mt-2 flex flex-row">
|
||||
<div className="w-1/2 text-base md:text-lg lg:w-2/3 lg:text-xl">File</div>
|
||||
<div className="w-1/2 text-base md:text-lg lg:w-1/3 lg:text-xl">Uploaded</div>
|
||||
</div>
|
||||
<div>
|
||||
{filesAttached.map((file, index) => (
|
||||
<div key={index} className="my-2 flex h-5 flex-row">
|
||||
<div className="lg:w flex w-1/2 flex-row content-center lg:w-2/3">
|
||||
<FileIcon className="m-0 size-5 p-0" />
|
||||
<div className="ml-2 content-center">{file.filename}</div>
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-row lg:w-1/3">
|
||||
<div className="content-center text-nowrap">{file.createdAt?.toString()}</div>
|
||||
<Button
|
||||
className="my-0 ml-3 h-min bg-transparent p-0 text-[#666666] hover:bg-slate-200"
|
||||
onClick={() => console.log('click')}
|
||||
>
|
||||
<NewTrashIcon className="m-0 p-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<b className="text-base md:text-lg lg:text-xl">Used by</b>
|
||||
<Button variant={'default'}>
|
||||
<PlusIcon className="h-4 w-4 font-bold" />
|
||||
Create Assistant
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y">
|
||||
<div className="mt-2 flex flex-row">
|
||||
<div className="w-1/2 text-base md:text-lg lg:w-2/3 lg:text-xl">Resource</div>
|
||||
<div className="w-1/2 text-base md:text-lg lg:w-1/3 lg:text-xl">ID</div>
|
||||
</div>
|
||||
<div>
|
||||
{assistants.map((assistant, index) => (
|
||||
<div key={index} className="flex flex-row">
|
||||
<div className="w-1/2 content-center lg:w-2/3">{assistant.resource}</div>
|
||||
<div className="flex w-1/2 flex-row lg:w-1/3">
|
||||
<div className="content-center">{assistant.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{open && <UploadFileModal open={open} onOpenChange={setOpen} />}
|
||||
</div>
|
||||
);
|
||||
}
|
252
client/src/components/Files/VectorStore/VectorStoreSidePanel.tsx
Normal file
252
client/src/components/Files/VectorStore/VectorStoreSidePanel.tsx
Normal file
|
@ -0,0 +1,252 @@
|
|||
import React from 'react';
|
||||
import VectorStoreList from './VectorStoreList';
|
||||
import { TVectorStore } from '~/common';
|
||||
import VectorStoreButton from './VectorStoreButton';
|
||||
import { Button, Input } from '~/components/ui';
|
||||
import FilesSectionSelector from '../FilesSectionSelector';
|
||||
import ActionButton from '../ActionButton';
|
||||
import DeleteIconButton from '../DeleteIconButton';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fakeVectorStores: TVectorStore[] = [
|
||||
{
|
||||
name: 'VectorStore 1',
|
||||
bytes: 10000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '1',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 2',
|
||||
bytes: 10000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '2',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 3',
|
||||
bytes: 10000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '3',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 4',
|
||||
bytes: 10000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '4',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 5',
|
||||
bytes: 10000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '5',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
{
|
||||
name: 'VectorStore 6',
|
||||
bytes: 2000,
|
||||
file_counts: {
|
||||
total: 10,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
},
|
||||
created_at: '2022-01-01T10:00:00',
|
||||
object: 'vector_store',
|
||||
_id: '6',
|
||||
},
|
||||
];
|
||||
|
||||
export default function VectorStoreSidePanel() {
|
||||
const localize = useLocalize();
|
||||
const deleteVectorStore = (id: string | undefined) => {
|
||||
// Define delete functionality here
|
||||
console.log(`Deleting VectorStore with id: ${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="m-3 flex max-h-[10vh] flex-col">
|
||||
<h2 className="text-lg">
|
||||
<strong>Vector Stores</strong>
|
||||
</h2>
|
||||
<div className="m-1 mt-2 flex w-full flex-row justify-between gap-x-2 lg:m-0">
|
||||
<div className="flex w-2/3 flex-row">
|
||||
<Button variant="ghost" className="m-0 mr-2 p-0">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
placeholder={localize('com_files_filter')}
|
||||
value={''}
|
||||
onChange={() => {
|
||||
console.log('changed');
|
||||
}}
|
||||
className="max-w-sm dark:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<VectorStoreButton
|
||||
onClick={() => {
|
||||
console.log('Add Vector Store');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-2 mt-2 max-h-[80vh] w-full overflow-y-auto">
|
||||
<VectorStoreList vectorStores={fakeVectorStores} deleteVectorStore={deleteVectorStore} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
43
client/src/components/Files/VectorStoreView.tsx
Normal file
43
client/src/components/Files/VectorStoreView.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import VectorStoreSidePanel from './VectorStore/VectorStoreSidePanel';
|
||||
import FilesSectionSelector from './FilesSectionSelector';
|
||||
import { Button } from '../ui';
|
||||
import { Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
export default function VectorStoreView() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="max-h-[100vh] bg-[#f9f9f9] p-0 lg:p-7">
|
||||
<div className="m-4 flex max-h-[10vh] w-full flex-row justify-between md:m-2">
|
||||
<FilesSectionSelector />
|
||||
<Button
|
||||
className="block lg:hidden"
|
||||
variant={'outline'}
|
||||
size={'sm'}
|
||||
onClick={() => {
|
||||
navigate('/d/vector-stores');
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex max-h-[90vh] w-full flex-row divide-x">
|
||||
<div
|
||||
className={`max-h-full w-full xl:w-1/3 ${
|
||||
params.vectorStoreId ? 'hidden w-1/2 lg:block lg:w-1/2' : 'md:w-full'
|
||||
}`}
|
||||
>
|
||||
<VectorStoreSidePanel />
|
||||
</div>
|
||||
<div
|
||||
className={`max-h-full w-full overflow-y-auto xl:w-2/3 ${
|
||||
params.vectorStoreId ? 'lg:w-1/2' : 'hidden md:w-1/2 lg:block'
|
||||
}`}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -8,7 +8,7 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
|
|||
return (
|
||||
<button
|
||||
onClick={scrollHandler}
|
||||
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-750/90 dark:text-gray-200"
|
||||
className="dark:bg-gray-850/90 absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
|
|
|
@ -103,7 +103,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
|
|||
<Tooltip>
|
||||
<div
|
||||
className={
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-750 md:max-w-[260px]'
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-850 md:max-w-[260px]'
|
||||
}
|
||||
style={{
|
||||
width: navVisible ? navWidth : '0px',
|
||||
|
|
|
@ -82,7 +82,7 @@ export default function NewChat({
|
|||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<div className="sticky left-0 right-0 top-0 z-20 bg-gray-50 pt-3.5 dark:bg-gray-750">
|
||||
<div className="sticky left-0 right-0 top-0 z-20 bg-gray-50 pt-3.5 dark:bg-gray-850">
|
||||
<div className="pb-0.5 last:pb-0" tabIndex={0} style={{ transform: 'none' }}>
|
||||
<a
|
||||
href="/"
|
||||
|
|
|
@ -58,7 +58,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative mt-1 flex flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-750 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
|
||||
className="relative mt-1 flex flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
|
||||
>
|
||||
{<Search className="absolute left-3 h-4 w-4" />}
|
||||
<input
|
||||
|
|
|
@ -7,8 +7,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/u
|
|||
import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { cn, formatBytes } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
function Avatar() {
|
||||
|
@ -55,8 +55,9 @@ function Avatar() {
|
|||
setinput(file);
|
||||
setDialogOpen(true);
|
||||
} else {
|
||||
const megabytes = fileConfig.avatarSizeLimit ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid'),
|
||||
message: localize('com_ui_upload_invalid_var', megabytes + ''),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
@ -81,7 +82,7 @@ function Avatar() {
|
|||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<label
|
||||
htmlFor={'file-upload-avatar'}
|
||||
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
|
||||
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
|
||||
>
|
||||
<FileImage className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
|
|
162
client/src/components/Prompts/AdminSettings.tsx
Normal file
162
client/src/components/Prompts/AdminSettings.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import { useMemo, useEffect } from 'react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui';
|
||||
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { Button, Switch } from '~/components/ui';
|
||||
import { useToastContext } from '~/Providers';
|
||||
|
||||
type FormValues = Record<Permissions, boolean>;
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
promptPerm: Permissions;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const defaultValues = roleDefaults[SystemRoles.USER];
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
promptPerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<label
|
||||
className="cursor-pointer select-none"
|
||||
htmlFor={promptPerm}
|
||||
onClick={() =>
|
||||
setValue(promptPerm, !getValues(promptPerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Controller
|
||||
name={promptPerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field?.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_endpoint_preset_saved') });
|
||||
},
|
||||
onError: () => {
|
||||
showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') });
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: useMemo(() => {
|
||||
if (roles?.[SystemRoles.USER]) {
|
||||
return roles[SystemRoles.USER][PermissionTypes.PROMPTS];
|
||||
}
|
||||
|
||||
return defaultValues[PermissionTypes.PROMPTS];
|
||||
}, [roles]),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[SystemRoles.USER]?.[PermissionTypes.PROMPTS]) {
|
||||
reset(roles[SystemRoles.USER][PermissionTypes.PROMPTS]);
|
||||
}
|
||||
}, [roles, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData = [
|
||||
{
|
||||
promptPerm: Permissions.SHARED_GLOBAL,
|
||||
label: localize('com_ui_prompts_allow_share_global'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.USE,
|
||||
label: localize('com_ui_prompts_allow_use'),
|
||||
},
|
||||
{
|
||||
promptPerm: Permissions.CREATE,
|
||||
label: localize('com_ui_prompts_allow_create'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: SystemRoles.USER, updates: data });
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" />
|
||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300">
|
||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||
'com_ui_prompts',
|
||||
)}`}</OGDialogTitle>
|
||||
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ promptPerm, label }) => (
|
||||
<LabelController
|
||||
key={promptPerm}
|
||||
control={control}
|
||||
promptPerm={promptPerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettings;
|
39
client/src/components/Prompts/AdvancedSwitch.tsx
Normal file
39
client/src/components/Prompts/AdvancedSwitch.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { Tabs, TabsList, TabsTrigger } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
const { PromptsEditorMode, promptsEditorMode, alwaysMakeProd } = store;
|
||||
|
||||
const AdvancedSwitch = () => {
|
||||
const localize = useLocalize();
|
||||
const [mode, setMode] = useRecoilState(promptsEditorMode);
|
||||
const setAlwaysMakeProd = useSetRecoilState(alwaysMakeProd);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={mode}
|
||||
className="w-auto rounded-lg"
|
||||
onValueChange={(value) => {
|
||||
value === PromptsEditorMode.SIMPLE && setAlwaysMakeProd(true);
|
||||
setMode(value);
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-auto grid-cols-2 bg-surface-tertiary">
|
||||
<TabsTrigger
|
||||
value={PromptsEditorMode.SIMPLE}
|
||||
className="w-20 min-w-0 rounded-md text-xs md:w-auto md:text-sm"
|
||||
>
|
||||
{localize('com_ui_simple')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={PromptsEditorMode.ADVANCED}
|
||||
className="w-20 min-w-0 rounded-md text-xs md:w-auto md:text-sm"
|
||||
>
|
||||
{localize('com_ui_advanced')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedSwitch;
|
26
client/src/components/Prompts/BackToChat.tsx
Normal file
26
client/src/components/Prompts/BackToChat.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { buttonVariants } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function BackToChat({ className }: { className?: string }) {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
navigate('/c/new');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<a
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
href="/"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<ArrowLeft className="icon-xs mr-2" />
|
||||
{localize('com_ui_back_to_chat')}
|
||||
</a>
|
||||
);
|
||||
}
|
60
client/src/components/Prompts/DeleteVersion.tsx
Normal file
60
client/src/components/Prompts/DeleteVersion.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { Button, Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DeleteVersion = ({
|
||||
name,
|
||||
disabled,
|
||||
selectHandler,
|
||||
}: {
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
selectHandler: () => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
className="h-10 w-10 border border-transparent bg-red-600 text-red-500 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="icon-lg cursor-pointer text-white dark:text-white" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_prompt')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label
|
||||
htmlFor="dialog-delete-confirm-prompt"
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{localize('com_ui_delete_confirm_prompt_version_var', name)}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler,
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteVersion;
|
63
client/src/components/Prompts/Description.tsx
Normal file
63
client/src/components/Prompts/Description.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const MAX_LENGTH = 56;
|
||||
|
||||
const Description = ({
|
||||
initialValue,
|
||||
onValueChange,
|
||||
disabled,
|
||||
tabIndex,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const [description, setDescription] = useState(initialValue || '');
|
||||
const [charCount, setCharCount] = useState(initialValue?.length || 0);
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(initialValue || '');
|
||||
setCharCount(initialValue?.length || 0);
|
||||
}, [initialValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setCharCount(description.length);
|
||||
}, [description]);
|
||||
|
||||
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.target.value.length <= MAX_LENGTH) {
|
||||
setDescription(e.target.value);
|
||||
onValueChange?.(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled && !description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border-medium">
|
||||
<h3 className="flex h-10 items-center gap-2 pl-4 text-sm text-text-secondary">
|
||||
<Info className="icon-sm" />
|
||||
<input
|
||||
type="text"
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
placeholder={localize('com_ui_description_placeholder')}
|
||||
value={description}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-lg border-none bg-transparent p-1 text-text-primary placeholder:text-text-tertiary placeholder:underline placeholder:underline-offset-2 focus:bg-surface-tertiary focus:outline-none focus:ring-0 md:w-96"
|
||||
/>
|
||||
{!disabled && (
|
||||
<span className="mr-1 w-10 text-xs text-text-tertiary md:text-sm">{`${charCount}/${MAX_LENGTH}`}</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Description;
|
9
client/src/components/Prompts/EmptyPromptPreview.tsx
Normal file
9
client/src/components/Prompts/EmptyPromptPreview.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function EmptyPromptPreview() {
|
||||
return (
|
||||
<div className="h-full w-full content-center text-center font-bold dark:text-gray-200">
|
||||
Select or Create a Prompt
|
||||
</div>
|
||||
);
|
||||
}
|
35
client/src/components/Prompts/Groups/AlwaysMakeProd.tsx
Normal file
35
client/src/components/Prompts/Groups/AlwaysMakeProd.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AlwaysMakeProd({
|
||||
onCheckedChange,
|
||||
className = '',
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [alwaysMakeProd, setAlwaysMakeProd] = useRecoilState<boolean>(store.alwaysMakeProd);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAlwaysMakeProd(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex select-none items-center justify-end gap-2 text-xs', className)}>
|
||||
<Switch
|
||||
id="alwaysMakeProd"
|
||||
checked={alwaysMakeProd}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
data-testid="alwaysMakeProd"
|
||||
/>
|
||||
<div>{localize('com_nav_always_make_prod')} </div>
|
||||
</div>
|
||||
);
|
||||
}
|
40
client/src/components/Prompts/Groups/AutoSendPrompt.tsx
Normal file
40
client/src/components/Prompts/Groups/AutoSendPrompt.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutoSendPrompt({
|
||||
onCheckedChange,
|
||||
className = '',
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [autoSendPrompts, setAutoSendPrompts] = useRecoilState<boolean>(store.autoSendPrompts);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutoSendPrompts(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex select-none items-center justify-end gap-2 text-right text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div> {localize('com_nav_auto_send_prompts')} </div>
|
||||
<Switch
|
||||
id="autoSendPrompts"
|
||||
checked={autoSendPrompts}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
data-testid="autoSendPrompts"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
52
client/src/components/Prompts/Groups/CategoryIcon.tsx
Normal file
52
client/src/components/Prompts/Groups/CategoryIcon.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Dices,
|
||||
BoxIcon,
|
||||
PenLineIcon,
|
||||
LightbulbIcon,
|
||||
LineChartIcon,
|
||||
ShoppingBagIcon,
|
||||
PlaneTakeoffIcon,
|
||||
GraduationCapIcon,
|
||||
TerminalSquareIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const categoryIconMap: Record<string, React.ElementType> = {
|
||||
misc: BoxIcon,
|
||||
roleplay: Dices,
|
||||
write: PenLineIcon,
|
||||
idea: LightbulbIcon,
|
||||
shop: ShoppingBagIcon,
|
||||
finance: LineChartIcon,
|
||||
code: TerminalSquareIcon,
|
||||
travel: PlaneTakeoffIcon,
|
||||
teach_or_explain: GraduationCapIcon,
|
||||
};
|
||||
|
||||
const categoryColorMap: Record<string, string> = {
|
||||
code: 'text-red-500',
|
||||
misc: 'text-blue-300',
|
||||
shop: 'text-purple-400',
|
||||
idea: 'text-yellow-300',
|
||||
write: 'text-purple-400',
|
||||
travel: 'text-yellow-300',
|
||||
finance: 'text-orange-400',
|
||||
roleplay: 'text-orange-400',
|
||||
teach_or_explain: 'text-blue-300',
|
||||
};
|
||||
|
||||
export default function CategoryIcon({
|
||||
category,
|
||||
className = '',
|
||||
}: {
|
||||
category: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const IconComponent = categoryIconMap[category];
|
||||
const colorClass = categoryColorMap[category] + ' ' + className;
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
return <IconComponent className={cn(colorClass, className)} />;
|
||||
}
|
60
client/src/components/Prompts/Groups/CategorySelector.tsx
Normal file
60
client/src/components/Prompts/Groups/CategorySelector.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useLocalize, useCategories } from '~/hooks';
|
||||
import { SelectDropDown } from '~/components/ui';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const CategorySelector = ({
|
||||
currentCategory,
|
||||
onValueChange,
|
||||
className = '',
|
||||
tabIndex,
|
||||
}: {
|
||||
currentCategory?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { control, watch, setValue } = useFormContext();
|
||||
const { categories, emptyCategory } = useCategories();
|
||||
|
||||
const watchedCategory = watch('category');
|
||||
const categoryOption = useMemo(
|
||||
() =>
|
||||
categories.find((category) => category.value === (watchedCategory ?? currentCategory)) ??
|
||||
emptyCategory,
|
||||
[watchedCategory, categories, currentCategory, emptyCategory],
|
||||
);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="category"
|
||||
control={control}
|
||||
render={() => (
|
||||
<SelectDropDown
|
||||
title="Category"
|
||||
tabIndex={tabIndex}
|
||||
value={categoryOption || ''}
|
||||
setValue={(value) => {
|
||||
setValue('category', value, { shouldDirty: false });
|
||||
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
availableValues={categories}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
showOptionIcon={true}
|
||||
searchPlaceholder={localize('com_ui_search_var', localize('com_ui_categories'))}
|
||||
className={cn('h-10 w-56 cursor-pointer', className)}
|
||||
currentValueClass="text-md gap-2"
|
||||
optionsListClass="text-sm max-h-72"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategorySelector;
|
114
client/src/components/Prompts/Groups/ChatGroupItem.tsx
Normal file
114
client/src/components/Prompts/Groups/ChatGroupItem.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { Menu as MenuIcon, Edit as EditIcon, EarthIcon, TextSearch } from 'lucide-react';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui';
|
||||
import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } 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';
|
||||
|
||||
export default function ChatGroupItem({
|
||||
group,
|
||||
instanceProjectId,
|
||||
}: {
|
||||
group: TPromptGroup;
|
||||
instanceProjectId?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { submitPrompt } = useSubmitMessage();
|
||||
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
|
||||
const onEditClick = useCustomLink<HTMLDivElement>(`/d/prompts/${group._id}`);
|
||||
const groupIsGlobal = useMemo(
|
||||
() => instanceProjectId && group?.projectIds?.includes(instanceProjectId),
|
||||
[group, instanceProjectId],
|
||||
);
|
||||
const isOwner = useMemo(() => user?.id === group?.author, [user, group]);
|
||||
|
||||
const onCardClick = () => {
|
||||
const text = group.productionPrompt?.prompt ?? '';
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const hasVariables = detectVariables(text);
|
||||
if (hasVariables) {
|
||||
return setVariableDialogOpen(true);
|
||||
}
|
||||
|
||||
submitPrompt(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListCard
|
||||
name={group.name}
|
||||
category={group.category ?? ''}
|
||||
onClick={onCardClick}
|
||||
snippet={group.oneliner ? group.oneliner : group?.productionPrompt?.prompt ?? ''}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{groupIsGlobal && <EarthIcon className="icon-md text-green-400" />}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="z-50 h-7 w-7 p-0 transition-all duration-300 ease-in-out hover:border-white dark:bg-gray-800 dark:hover:border-gray-400 dark:focus:border-gray-500"
|
||||
>
|
||||
<MenuIcon className="icon-md dark:text-gray-300" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="z-50 mt-2 w-36 rounded-lg"
|
||||
collisionPadding={2}
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewDialogOpen(true);
|
||||
}}
|
||||
className="w-full cursor-pointer rounded-lg disabled:cursor-not-allowed dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||
>
|
||||
<TextSearch className="mr-2 h-4 w-4" />
|
||||
<span>{localize('com_ui_preview')}</span>
|
||||
</DropdownMenuItem>
|
||||
{isOwner && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner}
|
||||
className="cursor-pointer rounded-lg disabled:cursor-not-allowed dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditClick(e);
|
||||
}}
|
||||
>
|
||||
<EditIcon className="mr-2 h-4 w-4" />
|
||||
<span>{localize('com_ui_edit')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ListCard>
|
||||
<PreviewPrompt group={group} open={isPreviewDialogOpen} onOpenChange={setPreviewDialogOpen} />
|
||||
<VariableDialog
|
||||
open={isVariableDialogOpen}
|
||||
onClose={() => setVariableDialogOpen(false)}
|
||||
group={group}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
178
client/src/components/Prompts/Groups/CreatePromptForm.tsx
Normal file
178
client/src/components/Prompts/Groups/CreatePromptForm.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
||||
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
|
||||
import PromptVariables from '~/components/Prompts/PromptVariables';
|
||||
import { Button, TextareaAutosize, Input } from '~/components/ui';
|
||||
import Description from '~/components/Prompts/Description';
|
||||
import { useCreatePrompt } from '~/data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type CreateFormValues = {
|
||||
name: string;
|
||||
prompt: string;
|
||||
type: 'text' | 'chat';
|
||||
category: string;
|
||||
oneliner?: string;
|
||||
};
|
||||
|
||||
const defaultPrompt: CreateFormValues = {
|
||||
name: '',
|
||||
prompt: '',
|
||||
type: 'text',
|
||||
category: '',
|
||||
oneliner: undefined,
|
||||
};
|
||||
|
||||
const CreatePromptForm = ({
|
||||
defaultValues = defaultPrompt,
|
||||
}: {
|
||||
defaultValues?: CreateFormValues;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const hasAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (!hasAccess) {
|
||||
timeoutId = setTimeout(() => {
|
||||
navigate('/c/new');
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [hasAccess, navigate]);
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
category: localStorage.getItem(LocalStorageKeys.LAST_PROMPT_CATEGORY) ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting, errors, isValid },
|
||||
} = methods;
|
||||
|
||||
const createPromptMutation = useCreatePrompt({
|
||||
onSuccess: (response) => {
|
||||
navigate(`/d/prompts/${response.prompt.groupId}`, { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const promptText = watch('prompt');
|
||||
|
||||
const onSubmit = (data: CreateFormValues) => {
|
||||
const { name, category, oneliner, ...rest } = data;
|
||||
const groupData = { name, category } as Pick<
|
||||
CreateFormValues,
|
||||
'name' | 'category' | 'oneliner'
|
||||
>;
|
||||
if ((oneliner?.length || 0) > 0) {
|
||||
groupData.oneliner = oneliner;
|
||||
}
|
||||
createPromptMutation.mutate({
|
||||
prompt: rest,
|
||||
group: groupData,
|
||||
});
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full px-4 py-2">
|
||||
<div className="mb-1 flex flex-col items-center justify-between font-bold sm:text-xl md:mb-0 md:text-2xl">
|
||||
<div className="flex w-full flex-col items-center justify-between sm:flex-row">
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: localize('com_ui_is_required', localize('com_ui_prompt_name')) }}
|
||||
render={({ field }) => (
|
||||
<div className="mb-1 flex items-center md:mb-0">
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
className="mr-2 w-full border border-gray-300 p-2 text-2xl dark:border-gray-600"
|
||||
placeholder={`${localize('com_ui_prompt_name')}*`}
|
||||
tabIndex={1}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 w-56 text-sm text-red-500',
|
||||
errors.name ? 'visible h-auto' : 'invisible h-0',
|
||||
)}
|
||||
>
|
||||
{errors.name ? errors.name.message : ' '}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<CategorySelector tabIndex={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:mt-[1.075rem]">
|
||||
<div>
|
||||
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 pr-1 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
|
||||
{localize('com_ui_text_prompt')}*
|
||||
</h2>
|
||||
<div className="mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
|
||||
<Controller
|
||||
name="prompt"
|
||||
control={control}
|
||||
rules={{ required: localize('com_ui_is_required', localize('com_ui_text_prompt')) }}
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 focus:outline-none dark:border-gray-600 dark:bg-transparent dark:text-gray-200"
|
||||
minRows={6}
|
||||
tabIndex={2}
|
||||
/>
|
||||
<div
|
||||
className={`mt-1 text-sm text-red-500 ${
|
||||
errors.prompt ? 'visible h-auto' : 'invisible h-0'
|
||||
}`}
|
||||
>
|
||||
{errors.prompt ? errors.prompt.message : ' '}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PromptVariables promptText={promptText} />
|
||||
<Description
|
||||
onValueChange={(value) => methods.setValue('oneliner', value)}
|
||||
tabIndex={3}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
tabIndex={5}
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={!isDirty || isSubmitting || !isValid}
|
||||
>
|
||||
{localize('com_ui_create_var', localize('com_ui_prompt'))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePromptForm;
|
217
client/src/components/Prompts/Groups/DashGroupItem.tsx
Normal file
217
client/src/components/Prompts/Groups/DashGroupItem.tsx
Normal file
|
@ -0,0 +1,217 @@
|
|||
import { useState, useRef, useMemo } from 'react';
|
||||
import { MenuIcon, EarthIcon } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { SystemRoles, type TPromptGroup } from 'librechat-data-provider';
|
||||
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Dialog,
|
||||
DropdownMenu,
|
||||
DialogTrigger,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '~/components/ui';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { RenameButton } from '~/components/Conversations';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { NewTrashIcon } from '~/components/svg';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
export default function DashGroupItem({
|
||||
group,
|
||||
instanceProjectId,
|
||||
}: {
|
||||
group: TPromptGroup;
|
||||
instanceProjectId?: string;
|
||||
}) {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
|
||||
const { user } = useAuthContext();
|
||||
const blurTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const [nameEditFlag, setNameEditFlag] = useState(false);
|
||||
const [nameInputField, setNameInputField] = useState(group.name);
|
||||
const isOwner = useMemo(() => user?.id === group?.author, [user, group]);
|
||||
const groupIsGlobal = useMemo(
|
||||
() => instanceProjectId && group?.projectIds?.includes(instanceProjectId),
|
||||
[group, instanceProjectId],
|
||||
);
|
||||
|
||||
const updateGroup = useUpdatePromptGroup({
|
||||
onMutate: () => {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
setNameEditFlag(false);
|
||||
},
|
||||
});
|
||||
const deletePromptGroupMutation = useDeletePromptGroup({
|
||||
onSuccess: (response, variables) => {
|
||||
if (variables.id === group._id) {
|
||||
navigate('/d/prompts');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cancelRename = () => {
|
||||
setNameEditFlag(false);
|
||||
};
|
||||
|
||||
const saveRename = () => {
|
||||
updateGroup.mutate({ payload: { name: nameInputField }, id: group?._id || '' });
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
cancelRename();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-100 mx-2 my-3 flex cursor-pointer flex-row rounded-md border-0 bg-white p-4 transition-all duration-300 ease-in-out hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
params.promptId === group._id && 'bg-gray-100/50 dark:bg-gray-600 ',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (nameEditFlag) {
|
||||
return;
|
||||
}
|
||||
navigate(`/d/prompts/${group._id}`, { replace: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-start truncate">
|
||||
{/* <Checkbox /> */}
|
||||
<div className="relative flex w-full cursor-pointer flex-col gap-1 text-start align-top">
|
||||
{nameEditFlag ? (
|
||||
<>
|
||||
<div className="flex w-full gap-2">
|
||||
<Input
|
||||
defaultValue={nameInputField}
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setNameInputField(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
cancelRename();
|
||||
} else if (e.key === 'Enter') {
|
||||
saveRename();
|
||||
}
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
className="w-min bg-green-500 text-white hover:bg-green-600 dark:bg-green-400 dark:hover:bg-green-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
saveRename();
|
||||
}}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="break-word line-clamp-3 text-balance text-sm text-gray-600 dark:text-gray-400">
|
||||
{localize('com_ui_renaming_var', group.name)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex flex-row gap-2">
|
||||
<CategoryIcon category={group.category ?? ''} className="icon-md" />
|
||||
<h3 className="break-word text-balance text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{group.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{groupIsGlobal && <EarthIcon className="icon-md text-green-400" />}
|
||||
{(isOwner || user?.role === SystemRoles.ADMIN) && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 w-7 p-0 hover:bg-gray-200 dark:bg-gray-800/50 dark:text-gray-400 dark:hover:border-gray-400 dark:focus:border-gray-500"
|
||||
>
|
||||
<MenuIcon className="icon-md dark:text-gray-300" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mt-2 w-36 rounded-lg" collisionPadding={2}>
|
||||
<DropdownMenuGroup>
|
||||
<RenameButton
|
||||
renaming={false}
|
||||
renameHandler={(e) => {
|
||||
e.stopPropagation();
|
||||
setNameEditFlag(true);
|
||||
}}
|
||||
appendLabel={true}
|
||||
className={cn('m-0 w-full p-2')}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 hover:bg-gray-200 dark:bg-gray-800/50 dark:text-gray-400 dark:hover:border-gray-400 dark:focus:border-gray-500',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<NewTrashIcon className="icon-md text-gray-600 dark:text-gray-300" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_prompt')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label
|
||||
htmlFor="chatGptLabel"
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{localize('com_ui_delete_confirm')}{' '}
|
||||
<strong>{group.name}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => {
|
||||
deletePromptGroupMutation.mutate({ id: group?._id || '' });
|
||||
},
|
||||
selectClasses:
|
||||
'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ellipsis text-balance text-sm text-gray-600 dark:text-gray-400">
|
||||
{group.oneliner ? group.oneliner : group?.productionPrompt?.prompt ?? ''}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
153
client/src/components/Prompts/Groups/FilterPrompts.tsx
Normal file
153
client/src/components/Prompts/Groups/FilterPrompts.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import { ListFilter, User, Share2, Dot } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { SystemCategories } from 'librechat-data-provider';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
} from '~/components/ui';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function FilterItem({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isActive,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={onClick}
|
||||
className="relative cursor-pointer gap-2 text-text-secondary hover:bg-surface-tertiary focus:bg-surface-tertiary dark:focus:bg-surface-tertiary"
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 right-0 top-0 flex items-center">
|
||||
<Dot />
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterMenu({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (category: string, icon?: React.ReactNode | null) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { categories } = useCategories('h-4 w-4');
|
||||
const memoizedCategories = useMemo(() => {
|
||||
const noCategory = {
|
||||
label: localize('com_ui_no_category'),
|
||||
value: SystemCategories.NO_CATEGORY,
|
||||
};
|
||||
if (!categories) {
|
||||
return [noCategory];
|
||||
}
|
||||
|
||||
return [noCategory, ...categories];
|
||||
}, [categories, localize]);
|
||||
|
||||
const categoryFilter = useRecoilValue(store.promptsCategory);
|
||||
return (
|
||||
<DropdownMenuContent className="max-h-xl min-w-48 overflow-y-auto">
|
||||
<DropdownMenuGroup>
|
||||
<FilterItem
|
||||
label={localize('com_ui_all_proper')}
|
||||
icon={<ListFilter className="h-4 w-4 text-text-primary" />}
|
||||
onClick={() => onSelect(SystemCategories.ALL, <ListFilter className="icon-sm" />)}
|
||||
isActive={categoryFilter === ''}
|
||||
/>
|
||||
<FilterItem
|
||||
label={localize('com_ui_my_prompts')}
|
||||
icon={<User className="h-4 w-4 text-text-primary" />}
|
||||
onClick={() => onSelect(SystemCategories.MY_PROMPTS, <User className="h-4 w-4" />)}
|
||||
isActive={categoryFilter === SystemCategories.MY_PROMPTS}
|
||||
/>
|
||||
<FilterItem
|
||||
label={localize('com_ui_shared_prompts')}
|
||||
icon={<Share2 className="h-4 w-4 text-text-primary" />}
|
||||
onClick={() => onSelect(SystemCategories.SHARED_PROMPTS, <Share2 className="h-4 w-4" />)}
|
||||
isActive={categoryFilter === SystemCategories.SHARED_PROMPTS}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{memoizedCategories
|
||||
.filter((category) => category.value)
|
||||
.map((category, i) => (
|
||||
<FilterItem
|
||||
key={`${category.value}-${i}`}
|
||||
label={category.label}
|
||||
icon={(category as OptionWithIcon).icon}
|
||||
onClick={() => onSelect(category.value, (category as OptionWithIcon).icon)}
|
||||
isActive={category.value === categoryFilter}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilterPrompts({
|
||||
setName,
|
||||
className = '',
|
||||
}: Pick<ReturnType<typeof usePromptGroupsNav>, 'setName'> & {
|
||||
className?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const setCategory = useSetRecoilState(store.promptsCategory);
|
||||
const [selectedIcon, setSelectedIcon] = useState(<ListFilter className="icon-sm" />);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(category: string, icon?: React.ReactNode | null) => {
|
||||
if (category === SystemCategories.ALL) {
|
||||
setSelectedIcon(<ListFilter className="icon-sm" />);
|
||||
return setCategory('');
|
||||
}
|
||||
setCategory(category);
|
||||
if (icon && React.isValidElement(icon)) {
|
||||
setSelectedIcon(icon);
|
||||
}
|
||||
},
|
||||
[setCategory],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-10 w-10 flex-shrink-0">
|
||||
{selectedIcon}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<FilterMenu onSelect={onSelect} />
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
placeholder={localize('com_ui_filter_prompts_name')}
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setName(e.target.value);
|
||||
}}
|
||||
className="max-w-xs border-border-light focus:bg-surface-tertiary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
55
client/src/components/Prompts/Groups/GroupSidePanel.tsx
Normal file
55
client/src/components/Prompts/Groups/GroupSidePanel.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
||||
import { useMediaQuery, usePromptGroupsNav } from '~/hooks';
|
||||
import List from '~/components/Prompts/Groups/List';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function GroupSidePanel({
|
||||
children,
|
||||
isDetailView,
|
||||
className = '',
|
||||
/* usePromptGroupsNav */
|
||||
nextPage,
|
||||
prevPage,
|
||||
isFetching,
|
||||
hasNextPage,
|
||||
groupsQuery,
|
||||
promptGroups,
|
||||
hasPreviousPage,
|
||||
}: {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 flex w-full min-w-72 flex-col gap-2 overflow-y-auto md:w-full lg:w-1/4 xl:w-1/4',
|
||||
isDetailView && isSmallerScreen ? 'hidden' : '',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<List
|
||||
groups={promptGroups}
|
||||
isChatRoute={isChatRoute}
|
||||
isLoading={!!groupsQuery?.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<PanelNavigation
|
||||
nextPage={nextPage}
|
||||
prevPage={prevPage}
|
||||
isFetching={isFetching}
|
||||
hasNextPage={hasNextPage}
|
||||
isChatRoute={isChatRoute}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
77
client/src/components/Prompts/Groups/List.tsx
Normal file
77
client/src/components/Prompts/Groups/List.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider';
|
||||
import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
|
||||
import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { Button, Skeleton } from '~/components/ui';
|
||||
|
||||
export default function List({
|
||||
groups = [],
|
||||
isChatRoute,
|
||||
isLoading,
|
||||
}: {
|
||||
groups?: TPromptGroup[];
|
||||
isChatRoute?: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig();
|
||||
const { instanceProjectId } = startupConfig;
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{hasCreateAccess && (
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mx-2 w-full px-3"
|
||||
onClick={() => navigate('/d/prompts/new')}
|
||||
>
|
||||
+ {localize('com_ui_create_var', localize('com_ui_prompt'))}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className="overflow-y-auto">
|
||||
{isLoading && isChatRoute && (
|
||||
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
||||
)}
|
||||
{isLoading && !isChatRoute && (
|
||||
<Skeleton className="w-100 mx-2 my-3 flex h-[72px] rounded-md border-0 p-4" />
|
||||
)}
|
||||
{!isLoading && groups.length === 0 && isChatRoute && (
|
||||
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
|
||||
{localize('com_ui_nothing_found')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && groups.length === 0 && !isChatRoute && (
|
||||
<div className="w-100 mx-2 my-3 flex h-[72px] items-center justify-center rounded-md border border-border-light bg-transparent p-4 text-text-primary">
|
||||
{localize('com_ui_nothing_found')}
|
||||
</div>
|
||||
)}
|
||||
{groups?.map((group) => {
|
||||
if (isChatRoute) {
|
||||
return (
|
||||
<ChatGroupItem
|
||||
key={group._id}
|
||||
group={group}
|
||||
instanceProjectId={instanceProjectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
36
client/src/components/Prompts/Groups/ListCard.tsx
Normal file
36
client/src/components/Prompts/Groups/ListCard.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
|
||||
export default function ListCard({
|
||||
category,
|
||||
name,
|
||||
snippet,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
category: string;
|
||||
name: string;
|
||||
snippet: string;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="relative my-2 flex w-full 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-all duration-300 ease-in-out hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex flex-row gap-2">
|
||||
<CategoryIcon category={category} className="icon-md" />
|
||||
<h3 className="break-word select-none text-balance text-sm font-semibold text-gray-800 dark:text-gray-200">
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
<div className="ellipsis select-none text-balance text-sm text-gray-600 dark:text-gray-400">
|
||||
{snippet}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
27
client/src/components/Prompts/Groups/NoPromptGroup.tsx
Normal file
27
client/src/components/Prompts/Groups/NoPromptGroup.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function NoPromptGroup() {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="relative min-h-full w-full px-4">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center font-bold dark:text-gray-200">
|
||||
<h1 className="text-lg font-bold dark:text-gray-200 md:text-2xl">
|
||||
{localize('com_ui_prompt_preview_not_shared')}
|
||||
</h1>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
navigate('/d/prompts');
|
||||
}}
|
||||
>
|
||||
{localize('com_ui_back_to_var', localize('com_ui_prompts'))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue