Repository navigation
🏷️ feat: Configurable and User-Created Prompt Categories #16574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TomasPalsson
wants to merge
25
commits into
LibreChat-AI:dev
Choose a base branch
from
TomasPalsson:feat/configurable-prompt-categories
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
c2fb780
feat: add prompts.categories config schema
TomasPalsson 358df48
docs: add prompts.categories sample to example config
TomasPalsson 5bd01cc
feat: add distinct custom prompt group categories query
TomasPalsson 29a69ff
feat: load prompts config into AppConfig and serve default prompt cat…
TomasPalsson 059eb4d
feat: validate prompt group category on create and update
TomasPalsson 7f38173
feat: resolve prompt category labels, icons and colors from configure…
TomasPalsson 43a4de6
fix: annotate prompt category schema types for isolated declarations
TomasPalsson 7f1ea72
test: cover configured and custom categories in prompt filter and ski…
TomasPalsson 11f80d6
feat: resolve per-user prompt categories and add categories handler f…
TomasPalsson bddcae0
feat: serve resolved per-user prompt categories and expose allowCusto…
TomasPalsson 82c3e34
feat: create custom prompt categories from the category selector and …
TomasPalsson f4ef6e6
fix: show placeholder for a selected category missing from the list u…
TomasPalsson f6b03cb
fix: gate display of unlisted selected category on allowCustom
TomasPalsson 019d6a1
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson 9c6c01d
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson ab7d8aa
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson 1526f95
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson 9346004
🎨 style: Order Tailwind Classes in CategorySelector
TomasPalsson 6de7e98
🐛 fix: Dedupe Prompt Categories by Exact Value and Trim Configured En…
TomasPalsson d7d5773
🐛 fix: Gate Stored Prompt Categories on Prompt Access and Content Policy
TomasPalsson a72ac72
🐛 fix: Refresh Prompt Categories After Prompt Group Deletion
TomasPalsson 70c3206
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson 5689ee4
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson 94fbd14
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson 41e93fe
🔀 chore: Merge dev into feat/configurable-prompt-categories
TomasPalsson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,31 @@ | ||
| const express = require('express'); | ||
| const { PermissionTypes, Permissions } = require('librechat-data-provider'); | ||
| const { checkAccess, createGetPromptCategoriesHandler } = require('@librechat/api'); | ||
| const { requireJwtAuth, configMiddleware } = require('~/server/middleware'); | ||
| const { | ||
| getRoleByName, | ||
| getPromptGroupAccessContext, | ||
| getDistinctPromptGroupCategories, | ||
| } = require('~/models'); | ||
|
|
||
| const router = express.Router(); | ||
| const { requireJwtAuth } = require('~/server/middleware'); | ||
| const { getCategories } = require('~/models'); | ||
|
|
||
| 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 }); | ||
| } | ||
| }); | ||
| router.get( | ||
| '/', | ||
| requireJwtAuth, | ||
| configMiddleware, | ||
| createGetPromptCategoriesHandler({ | ||
| getPromptGroupAccessContext, | ||
| getDistinctPromptGroupCategories, | ||
| canUsePrompts: (req) => | ||
| checkAccess({ | ||
| req, | ||
| user: req.user, | ||
| permissionType: PermissionTypes.PROMPTS, | ||
| permissions: [Permissions.USE], | ||
| getRoleByName, | ||
| }), | ||
| }), | ||
| ); | ||
|
|
||
| module.exports = router; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| const express = require('express'); | ||
| const request = require('supertest'); | ||
| const mongoose = require('mongoose'); | ||
| const { MongoMemoryServer } = require('mongodb-memory-server'); | ||
| const { | ||
| SystemRoles, | ||
| ResourceType, | ||
| AccessRoleIds, | ||
| PrincipalType, | ||
| PermissionBits, | ||
| PermissionTypes, | ||
| Permissions, | ||
| } = require('librechat-data-provider'); | ||
| let mockBaseConfig = {}; | ||
|
|
||
| const mockHrOverride = { | ||
| principalType: 'role', | ||
| principalId: 'HR', | ||
| priority: 10, | ||
| overrides: { | ||
| prompts: { | ||
| categories: { | ||
| enableDefaultCategories: false, | ||
| list: [{ value: 'benefits', label: 'Benefits' }], | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| jest.mock('~/server/services/Config', () => ({ | ||
| getAppConfig: jest.fn(async ({ role } = {}) => { | ||
| const { mergeConfigOverrides } = require('@librechat/data-schemas'); | ||
| return role === 'HR' ? mergeConfigOverrides(mockBaseConfig, [mockHrOverride]) : mockBaseConfig; | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock('~/models', () => { | ||
| const mongoose = require('mongoose'); | ||
| const { createMethods } = require('@librechat/data-schemas'); | ||
| const methods = createMethods(mongoose, { | ||
| removeAllPermissions: async ({ resourceType, resourceId }) => { | ||
| await mongoose.models.AclEntry?.deleteMany({ resourceType, resourceId }); | ||
| }, | ||
| }); | ||
| return { | ||
| ...methods, | ||
| getPromptGroupAccessContext: jest.fn(methods.getPromptGroupAccessContext), | ||
| getDistinctPromptGroupCategories: jest.fn(methods.getDistinctPromptGroupCategories), | ||
| }; | ||
| }); | ||
|
|
||
| jest.mock('~/server/middleware', () => ({ | ||
| requireJwtAuth: (req, res, next) => next(), | ||
| configMiddleware: jest.requireActual('~/server/middleware/config/app'), | ||
| })); | ||
|
|
||
| const builtins = [ | ||
| { label: 'com_ui_idea', value: 'idea' }, | ||
| { label: 'com_ui_travel', value: 'travel' }, | ||
| { label: 'com_ui_teach_or_explain', value: 'teach_or_explain' }, | ||
| { label: 'com_ui_write', value: 'write' }, | ||
| { label: 'com_ui_shop', value: 'shop' }, | ||
| { label: 'com_ui_code', value: 'code' }, | ||
| { label: 'com_ui_misc', value: 'misc' }, | ||
| { label: 'com_ui_roleplay', value: 'roleplay' }, | ||
| { label: 'com_ui_finance', value: 'finance' }, | ||
| ]; | ||
|
|
||
| let app; | ||
| let mongoServer; | ||
| let models; | ||
| let users; | ||
| let currentUser; | ||
|
|
||
| beforeAll(async () => { | ||
| mongoServer = await MongoMemoryServer.create(); | ||
| await mongoose.connect(mongoServer.getUri()); | ||
|
|
||
| const { AccessRole, User, Role } = require('~/db/models'); | ||
| await Role.create({ | ||
| name: 'NO_PROMPTS', | ||
| permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false } }, | ||
| }); | ||
| await AccessRole.create({ | ||
| accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, | ||
| name: 'Owner', | ||
| resourceType: ResourceType.PROMPTGROUP, | ||
| permBits: | ||
| PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE, | ||
| }); | ||
| users = { | ||
| a: await User.create({ name: 'A', email: 'a@example.com', role: SystemRoles.USER }), | ||
| b: await User.create({ name: 'B', email: 'b@example.com', role: SystemRoles.USER }), | ||
| hr: await User.create({ name: 'HR', email: 'hr@example.com', role: 'HR' }), | ||
| noPrompts: await User.create({ name: 'NP', email: 'np@example.com', role: 'NO_PROMPTS' }), | ||
| }; | ||
| models = require('~/models'); | ||
|
|
||
| app = express(); | ||
| app.use((req, res, next) => { | ||
| req.user = { | ||
| id: currentUser._id.toString(), | ||
| _id: currentUser._id, | ||
| role: currentUser.role, | ||
| }; | ||
| next(); | ||
| }); | ||
| app.use('/api/categories', require('./categories')); | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| currentUser = users.a; | ||
| mockBaseConfig = {}; | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await mongoose.disconnect(); | ||
| await mongoServer.stop(); | ||
| }); | ||
|
|
||
| describe('GET /api/categories', () => { | ||
| it('defaults unchanged when no prompts config is set', async () => { | ||
| const res = await request(app).get('/api/categories'); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(res.body).toEqual(builtins); | ||
| }); | ||
|
|
||
| it('applies the role override per requesting role', async () => { | ||
| currentUser = users.hr; | ||
| const hr = await request(app).get('/api/categories'); | ||
| expect(hr.status).toBe(200); | ||
| expect(hr.body).toEqual([{ value: 'benefits', label: 'Benefits' }]); | ||
|
|
||
| currentUser = users.a; | ||
| const user = await request(app).get('/api/categories'); | ||
| expect(user.body).toEqual(builtins); | ||
| }); | ||
|
|
||
| it('gives no error leak when the custom category read fails', async () => { | ||
| mockBaseConfig = { prompts: { categories: { allowCustom: true } } }; | ||
| models.getDistinctPromptGroupCategories.mockRejectedValueOnce(new Error('secret db detail')); | ||
|
|
||
| const res = await request(app).get('/api/categories'); | ||
|
|
||
| expect(res.status).toBe(500); | ||
| expect(res.body).toEqual({ message: 'Failed to retrieve categories' }); | ||
| expect(res.text).not.toContain('secret'); | ||
| }); | ||
|
|
||
| it('keeps custom categories access scoped to the requesting user', async () => { | ||
| mockBaseConfig = { prompts: { categories: { allowCustom: true } } }; | ||
| const { grantPermission } = require('~/server/services/PermissionService'); | ||
| const { group } = await models.createPromptGroup({ | ||
| prompt: { prompt: 'secret text', type: 'text' }, | ||
| group: { name: 'private group', category: 'A-Private' }, | ||
| author: users.a._id.toString(), | ||
| authorName: users.a.name, | ||
| }); | ||
| await grantPermission({ | ||
| principalType: PrincipalType.USER, | ||
| principalId: users.a._id, | ||
| resourceType: ResourceType.PROMPTGROUP, | ||
| resourceId: group._id, | ||
| accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, | ||
| grantedBy: users.a._id, | ||
| }); | ||
|
|
||
| currentUser = users.b; | ||
| const forB = await request(app).get('/api/categories'); | ||
| expect(forB.body.map((c) => c.value)).not.toContain('A-Private'); | ||
|
|
||
| currentUser = users.a; | ||
| const forA = await request(app).get('/api/categories'); | ||
| expect(forA.body).toContainEqual({ value: 'A-Private', label: 'A-Private', custom: true }); | ||
| }); | ||
|
|
||
| it('serves configured categories only to a user without prompt-use permission', async () => { | ||
| mockBaseConfig = { | ||
| prompts: { | ||
| categories: { allowCustom: true, enableDefaultCategories: false, list: [{ value: 'hr' }] }, | ||
| }, | ||
| }; | ||
| const { grantPermission } = require('~/server/services/PermissionService'); | ||
| const { group } = await models.createPromptGroup({ | ||
| prompt: { prompt: 'text', type: 'text' }, | ||
| group: { name: 'shared group', category: 'Stored' }, | ||
| author: users.noPrompts._id.toString(), | ||
| authorName: users.noPrompts.name, | ||
| }); | ||
| await grantPermission({ | ||
| principalType: PrincipalType.USER, | ||
| principalId: users.noPrompts._id, | ||
| resourceType: ResourceType.PROMPTGROUP, | ||
| resourceId: group._id, | ||
| accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, | ||
| grantedBy: users.noPrompts._id, | ||
| }); | ||
|
|
||
| currentUser = users.noPrompts; | ||
| const denied = await request(app).get('/api/categories'); | ||
| expect(denied.status).toBe(200); | ||
| expect(denied.body).toEqual([{ value: 'hr', label: 'hr' }]); | ||
| expect(models.getDistinctPromptGroupCategories).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('no reads when off: custom categories disabled', async () => { | ||
| mockBaseConfig = { prompts: { categories: { allowCustom: false } } }; | ||
|
|
||
| const res = await request(app).get('/api/categories'); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(models.getPromptGroupAccessContext).not.toHaveBeenCalled(); | ||
| expect(models.getDistinctPromptGroupCategories).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Enforce prompt-use permission before exposing stored categories
This route authenticates the user but does not enforce
PROMPTS.USE, unlike/api/prompts.getPromptGroupAccessContextresolves resource ACLs independently of that role permission. With custom categories enabled, a user denied access to the Prompt Library can still retrieve stored category names through retained VIEW ACLs or public grants. Gate stored-category discovery on the existing prompt-use permission check. Configured categories can remain available to shared consumers such as Skills.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d7d5773. The route now checks
PROMPTS.USE; a user without it gets configured categories only, with no stored reads. Covered byserves configured categories only to a user without prompt-use permissioninapi/server/routes/categories.test.jsand the matching case incategories.spec.ts.