From c2fb780577fd0b239a05ac01fd4577d2c1ac3fbf Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Wed, 30 Sep 2026 16:34:56 +0000 Subject: [PATCH 01/17] feat: add prompts.categories config schema --- packages/data-provider/specs/config.spec.ts | 73 ++++++++++++++++ packages/data-provider/src/config.ts | 97 ++++++++++++++++++++- packages/data-provider/src/types.ts | 14 +-- 3 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 packages/data-provider/specs/config.spec.ts diff --git a/packages/data-provider/specs/config.spec.ts b/packages/data-provider/specs/config.spec.ts new file mode 100644 index 00000000000..f3a2c9e0bd1 --- /dev/null +++ b/packages/data-provider/specs/config.spec.ts @@ -0,0 +1,73 @@ +import { promptsConfigSchema, defaultPromptCategories, configSchema } from '../src/config'; + +const parse = (categories: unknown) => promptsConfigSchema.safeParse({ categories }); +const entries = (n: number) => Array.from({ length: n }, (_, i) => ({ value: `cat${i}` })); + +describe('prompts.categories', () => { + it('parses a valid full config', () => { + const result = parse({ + enableDefaultCategories: false, + allowCustom: true, + list: [{ value: 'legal', label: 'Legal', icon: 'settings', color: 'series-3' }], + }); + expect(result.success).toBe(true); + }); + + it('parses when the section is missing', () => { + expect(promptsConfigSchema.safeParse(undefined).success).toBe(true); + expect(configSchema.safeParse({ version: '1.0.0' }).success).toBe(true); + }); + + it('rejects duplicate values differing by case or whitespace at the duplicate entry', () => { + const result = parse({ list: [{ value: 'Legal' }, { value: 'other' }, { value: ' legal ' }] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['categories', 'list', 2, 'value']); + } + }); + + it('rejects values with the reserved system prefix', () => { + expect(parse({ list: [{ value: 'sys__x' }] }).success).toBe(false); + }); + + it('rejects empty values', () => { + expect(parse({ list: [{ value: '' }] }).success).toBe(false); + expect(parse({ list: [{ value: ' ' }] }).success).toBe(false); + }); + + it('rejects control characters', () => { + expect(parse({ list: [{ value: 'a\u0007b' }] }).success).toBe(false); + }); + + it('rejects a 101-character value and accepts 100', () => { + expect(parse({ list: [{ value: 'a'.repeat(101) }] }).success).toBe(false); + expect(parse({ list: [{ value: 'a'.repeat(100) }] }).success).toBe(true); + }); + + it('rejects more than 50 entries and accepts 50', () => { + expect(parse({ list: entries(51) }).success).toBe(false); + expect(parse({ list: entries(50) }).success).toBe(true); + }); + + it('rejects an unknown icon', () => { + expect(parse({ list: [{ value: 'a', icon: 'rocket' }] }).success).toBe(false); + }); + + it('rejects an unknown color', () => { + expect(parse({ list: [{ value: 'a', color: 'series-9' }] }).success).toBe(false); + }); + + it('defaultPromptCategories matches the built-in categories', () => { + expect(defaultPromptCategories).toEqual([ + { 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' }, + ]); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 8f858dd872b..12302009ec1 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import type { ZodError } from 'zod'; -import type { TEndpointsConfig, TModelsConfig, TConfig } from './types'; +import type { TEndpointsConfig, TModelsConfig, TCategory, TConfig } from './types'; import { filtersConfigSchema, MAX_PII_CUSTOM_REGEX_CHARACTERS, @@ -2512,6 +2512,7 @@ export type EndpointsDropParamsMap = Record; /** Maximum CAS attempts per ACL document, including the initial attempt. */ export const permissionWriteAttemptsSchema = z.number().int().min(1).max(100).default(3); +export const SYSTEM_CATEGORY_PREFIX = 'sys__'; + +export const promptCategoryIcons = [ + 'dices', + 'box', + 'file-text', + 'pen-line', + 'lightbulb', + 'line-chart', + 'shopping-bag', + 'plane-takeoff', + 'graduation-cap', + 'terminal-square', + 'users', + 'beaker', + 'settings', +] as const; +export type PromptCategoryIcon = (typeof promptCategoryIcons)[number]; + +export const promptCategoryColors = [ + 'series-1', + 'series-2', + 'series-3', + 'series-4', + 'series-5', + 'series-6', + 'series-7', + 'series-8', +] as const; +export type PromptCategoryColor = (typeof promptCategoryColors)[number]; + +export const promptCategoryValueSchema = z + .string() + .trim() + .min(1) + .max(100) + .refine((value) => !value.startsWith(SYSTEM_CATEGORY_PREFIX), { + message: `Category values cannot start with ${SYSTEM_CATEGORY_PREFIX}`, + }) + .refine( + (value) => + !value.split('').some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127), + { + message: 'Category values cannot contain control characters', + }, + ); + +export const promptCategoryEntrySchema = z.object({ + value: promptCategoryValueSchema, + label: z.string().trim().min(1).max(100).optional(), + icon: z.enum(promptCategoryIcons).optional(), + color: z.enum(promptCategoryColors).optional(), +}); + +export const promptsConfigSchema = z + .object({ + categories: z + .object({ + enableDefaultCategories: z.boolean().optional(), + allowCustom: z.boolean().optional(), + list: z.array(promptCategoryEntrySchema).max(50).optional(), + }) + .optional(), + }) + .superRefine((config, ctx) => { + const seen = new Set(); + config.categories?.list?.forEach((entry, i) => { + const key = entry.value.toLowerCase(); + if (seen.has(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate category value: ${entry.value}`, + path: ['categories', 'list', i, 'value'], + }); + } + seen.add(key); + }); + }) + .optional(); +export type TPromptsConfig = z.infer; + +export const defaultPromptCategories: TCategory[] = [ + { 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' }, +]; + export const configSchema = z.object({ version: z.string(), + prompts: promptsConfigSchema, permissions: z.object({ maxWriteAttempts: permissionWriteAttemptsSchema }).optional(), cache: z.boolean().default(true), ocr: ocrSchema.optional(), diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 681188237e2..ada0a73d1bc 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -10,17 +10,19 @@ import type { ReasoningResponseKey, ReasoningParameterFormat, } from './schemas'; +import type { + CodeEnvironmentUserConfigSchema, + CodeEnvironmentUserSettings, + PromptCategoryColor, + PromptCategoryIcon, + TAgentsEndpoint, +} from './config'; import type { CodeWorkspaceDescriptor, CodeEnvironmentMode, CodeWorkspaceOperation, CodeWorkspaceSelection, } from './code/workspace'; -import type { - CodeEnvironmentUserConfigSchema, - CodeEnvironmentUserSettings, - TAgentsEndpoint, -} from './config'; import type { StatefulCodeEnvironment } from './stateful-code'; import type { CodeApprovalMode } from './code/approval'; import type { EToolResources } from './types/tools'; @@ -288,6 +290,8 @@ export type TCategory = { value: string; label: string; description?: string; + icon?: PromptCategoryIcon; + color?: PromptCategoryColor; custom?: boolean; }; From 358df4806fa0818c48dcde76cb9a763c6b816cc6 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Wed, 30 Sep 2026 16:38:24 +0000 Subject: [PATCH 02/17] docs: add prompts.categories sample to example config --- librechat.example.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/librechat.example.yaml b/librechat.example.yaml index 8d0f893c9b2..5e0f365c8cf 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -340,6 +340,21 @@ interface: # retentionMode: "all"; non-agent files still expire. # retainAgentFiles: false +# Prompt library categories (optional) +# Without this section the nine built-in categories are shown, as before. +# prompts: +# categories: +# enableDefaultCategories: true # false hides the built-ins; list entries still show +# allowCustom: false # true lets authors create categories when saving a prompt +# list: # up to 50 entries; values must be unique and cannot start with "sys__" +# - value: "hr" +# label: "Human Resources" # "com_" labels are translated; others show as written +# icon: "users" # dices, box, file-text, pen-line, lightbulb, line-chart, shopping-bag, +# # plane-takeoff, graduation-cap, terminal-square, users, beaker, settings +# color: "series-7" # series-1 … series-8 +# - value: "code" # matching a built-in overrides its label, icon and color in place +# label: "Engineering" + # Example Cloudflare turnstile (optional) #turnstile: # siteKey: "your-site-key-here" From 5bd01cc8fe60cd794c23287f05bc5fd7be960cae Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Wed, 30 Sep 2026 16:54:51 +0000 Subject: [PATCH 03/17] feat: add distinct custom prompt group categories query --- .../data-schemas/src/methods/prompt.spec.ts | 58 ++++++++++++++++++- packages/data-schemas/src/methods/prompt.ts | 34 +++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/data-schemas/src/methods/prompt.spec.ts b/packages/data-schemas/src/methods/prompt.spec.ts index 6a02b8bc3bd..dc312beac41 100644 --- a/packages/data-schemas/src/methods/prompt.spec.ts +++ b/packages/data-schemas/src/methods/prompt.spec.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { ObjectId } from 'mongodb'; +import { logger, createModels } from '..'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { SystemRoles, @@ -10,7 +11,6 @@ import { } from 'librechat-data-provider'; import type { IPromptGroup, AccessRole as TAccessRole, AclEntry as TAclEntry } from '..'; import { createAclEntryMethods } from './aclEntry'; -import { logger, createModels } from '..'; import { createMethods } from './index'; // Disable console for tests @@ -623,3 +623,59 @@ describe('Prompt ACL Permissions', () => { }); }); }); + +describe('distinct categories', () => { + const seed = async (categories: string[]) => { + const docs = await PromptGroup.insertMany( + categories.map((category, i) => ({ + name: `Distinct Group ${i}`, + category, + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new mongoose.Types.ObjectId(), + })), + ); + return docs.map((doc) => doc._id as mongoose.Types.ObjectId); + }; + + afterEach(async () => { + await PromptGroup.deleteMany({ name: /^Distinct Group/ }); + }); + + it('reads only groups whose ids are passed', async () => { + const [visible] = await seed(['Visible', 'Secret']); + expect(await methods.getDistinctPromptGroupCategories([visible])).toEqual(['Visible']); + }); + + it('excludes empty categories, system-prefixed values and values over 100 characters', async () => { + const ids = await seed(['', 'sys__x', 'a'.repeat(101), 'Keep']); + expect(await methods.getDistinctPromptGroupCategories(ids)).toEqual(['Keep']); + }); + + it('trims values and drops duplicates after trimming', async () => { + const ids = await seed([' Alpha ', 'Alpha']); + expect(await methods.getDistinctPromptGroupCategories(ids)).toEqual(['Alpha']); + }); + + it('sorts case-insensitively', async () => { + const ids = await seed(['beta', 'Alpha', 'gamma']); + expect(await methods.getDistinctPromptGroupCategories(ids)).toEqual(['Alpha', 'beta', 'gamma']); + }); + + it('caps the result at 200 values', async () => { + const ids = await seed( + Array.from({ length: 250 }, (_, i) => `cat-${String(i).padStart(3, '0')}`), + ); + const result = await methods.getDistinctPromptGroupCategories(ids); + expect(result).toHaveLength(200); + expect(result[0]).toBe('cat-000'); + expect(result[199]).toBe('cat-199'); + }); + + it('returns [] without querying when no ids are passed', async () => { + const spy = jest.spyOn(PromptGroup, 'distinct'); + expect(await methods.getDistinctPromptGroupCategories([])).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/packages/data-schemas/src/methods/prompt.ts b/packages/data-schemas/src/methods/prompt.ts index 40cad9c6b7c..0fc3e86feaf 100644 --- a/packages/data-schemas/src/methods/prompt.ts +++ b/packages/data-schemas/src/methods/prompt.ts @@ -5,6 +5,7 @@ import { ResourceType, SystemCategories, Time, + promptCategoryValueSchema, } from 'librechat-data-provider'; import type { Model, Types } from 'mongoose'; import type { IAclEntry, CacheStore, IPrompt, IPromptGroup, IPromptGroupDocument } from '~/types'; @@ -91,6 +92,8 @@ async function readAccessGeneration(cache: CacheStore): Promise): Promise< | { @@ -118,6 +121,7 @@ export interface PromptMethods { has_more: boolean; after: string | null; }>; + getDistinctPromptGroupCategories(accessibleIds: Types.ObjectId[]): Promise; incrementPromptGroupUsage(groupId: string): Promise<{ numberOfGenerations: number }>; createPromptGroup(saveData: { prompt: Record; @@ -483,6 +487,35 @@ export function createPromptMethods( }; } + /** + * Get the sorted, validated custom category values used by the given prompt groups. + */ + async function getDistinctPromptGroupCategories( + accessibleIds: Types.ObjectId[], + ): Promise { + if (accessibleIds.length === 0) { + return []; + } + + const PromptGroup = mongoose.models.PromptGroup as Model; + const stored = await PromptGroup.distinct('category', { + _id: { $in: accessibleIds }, + category: { $nin: ['', null] }, + }); + + const values = new Set(); + for (const category of stored) { + const parsed = promptCategoryValueSchema.safeParse(category); + if (parsed.success) { + values.add(parsed.data); + } + } + + return [...values] + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + .slice(0, MAX_CUSTOM_PROMPT_CATEGORIES); + } + /** * Increment the numberOfGenerations counter for a prompt group. */ @@ -1154,6 +1187,7 @@ export function createPromptMethods( deletePromptGroup, getAllPromptGroups, getListPromptGroupsByAccess, + getDistinctPromptGroupCategories, incrementPromptGroupUsage, createPromptGroup, savePrompt, From 29a69ffb5079c6fe9de4c02e6afa848c8450a2ee Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Wed, 30 Sep 2026 16:55:05 +0000 Subject: [PATCH 04/17] feat: load prompts config into AppConfig and serve default prompt categories --- packages/data-schemas/src/app/service.spec.ts | 21 +++++++++++++++++++ packages/data-schemas/src/app/service.ts | 1 + .../data-schemas/src/methods/categories.ts | 15 ++----------- packages/data-schemas/src/types/app.ts | 3 +++ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/data-schemas/src/app/service.spec.ts b/packages/data-schemas/src/app/service.spec.ts index 820335f4b5d..e75b9c12b22 100644 --- a/packages/data-schemas/src/app/service.spec.ts +++ b/packages/data-schemas/src/app/service.spec.ts @@ -321,3 +321,24 @@ describe('AppService memory capability', () => { ); }); }); + +describe('AppService prompts config', () => { + it('loads the prompts section into the app config', async () => { + const config = { + prompts: { + categories: { + enableDefaultCategories: false, + allowCustom: true, + list: [{ value: 'support', label: 'Support' }], + }, + }, + } as DeepPartial; + const result = await AppService({ config }); + expect(result.prompts).toEqual(config.prompts); + }); + + it('leaves prompts undefined when the section is absent', async () => { + const result = await AppService({ config: {} }); + expect(result.prompts).toBeUndefined(); + }); +}); diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts index e762d1620e8..fa3d7084992 100644 --- a/packages/data-schemas/src/app/service.ts +++ b/packages/data-schemas/src/app/service.ts @@ -175,6 +175,7 @@ export const AppService = async (params?: { paths, config, memory, + prompts: config.prompts as AppConfig['prompts'], speech, actions, balance, diff --git a/packages/data-schemas/src/methods/categories.ts b/packages/data-schemas/src/methods/categories.ts index 3fb86929437..416992f14fc 100644 --- a/packages/data-schemas/src/methods/categories.ts +++ b/packages/data-schemas/src/methods/categories.ts @@ -1,17 +1,6 @@ +import { defaultPromptCategories } from 'librechat-data-provider'; import logger from '~/config/winston'; -const options = [ - { 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' }, -] as const; - export type CategoryOption = { label: string; value: string }; export function createCategoriesMethods(_mongoose: typeof import('mongoose')): { @@ -22,7 +11,7 @@ export function createCategoriesMethods(_mongoose: typeof import('mongoose')): { */ async function getCategories(): Promise { try { - return [...options]; + return defaultPromptCategories.map((c) => ({ ...c })); } catch (error) { logger.error('Error getting categories', error); return []; diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts index df2a4b60540..2c8d9da2dd9 100644 --- a/packages/data-schemas/src/types/app.ts +++ b/packages/data-schemas/src/types/app.ts @@ -5,6 +5,7 @@ import type { TAzureConfig, TCustomConfig, TMemoryConfig, + TPromptsConfig, EModelEndpoint, TVertexAIConfig, TAgentsEndpoint, @@ -49,6 +50,8 @@ export interface FunctionTool { export interface AppConfig { /** The main custom configuration */ config: Partial; + /** Prompt library configuration */ + prompts?: TPromptsConfig; /** OCR configuration */ ocr?: TCustomConfig['ocr']; /** File paths configuration */ From 059eb4dd0e284b67199899bb5a40935e6b4dd3ad Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Wed, 30 Sep 2026 16:56:14 +0000 Subject: [PATCH 05/17] feat: validate prompt group category on create and update --- api/server/routes/prompts.js | 12 ++++++++ api/server/routes/prompts.test.js | 35 ++++++++++++++++++++++++ packages/api/src/prompts/schemas.spec.ts | 25 +++++++++++++++++ packages/api/src/prompts/schemas.ts | 12 ++++++-- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index f166a907e32..4a73cd63a5a 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -7,6 +7,7 @@ const { buildPromptGroupFilter, formatPromptGroupsResponse, safeValidatePromptGroupUpdate, + safeValidatePromptGroupCategory, createEmptyPromptGroupsResponse, filterAccessibleIdsBySharedLogic, inspectPromptContent, @@ -256,6 +257,17 @@ const createNewPromptGroup = async (req, res) => { return res.status(400).send({ error: 'Prompt and group name are required' }); } + if (group.category !== undefined) { + const categoryResult = safeValidatePromptGroupCategory(group.category); + if (!categoryResult.success) { + return res.status(400).send({ + error: 'Invalid request body', + details: categoryResult.error.errors, + }); + } + group.category = categoryResult.data; + } + if (blockFilteredPromptContent(req, res, { prompt, group })) { return; } diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index b8e06f8094e..94ae3c90ae3 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -311,6 +311,31 @@ describe('Prompt Routes - ACL Permissions', () => { await expect(PromptGroup.countDocuments()).resolves.toBe(0); }); + it('should reject a reserved category on create', async () => { + const response = await request(app) + .post('/api/prompts') + .send({ + prompt: { prompt: 'Category prompt', type: 'text' }, + group: { name: 'Category Group', category: 'sys__x' }, + }); + + expect(response.status).toBe(400); + await expect(PromptGroup.countDocuments()).resolves.toBe(0); + }); + + it('should store a valid custom category trimmed on create', async () => { + const response = await request(app) + .post('/api/prompts') + .send({ + prompt: { prompt: 'Category prompt', type: 'text' }, + group: { name: 'Category Group', category: ' Onboarding ' }, + }); + + expect(response.status).toBe(200); + const stored = await PromptGroup.findOne({ name: 'Category Group' }).lean(); + expect(stored.category).toBe('Onboarding'); + }); + it('should create a prompt and grant owner permissions', async () => { const promptData = { prompt: { @@ -893,6 +918,16 @@ describe('Prompt Routes - ACL Permissions', () => { await AclEntry.deleteMany({}); }); + it('should reject an over-long category on update and keep the stored category', async () => { + await request(app) + .patch(`/api/prompts/groups/${testGroup._id}`) + .send({ category: 'a'.repeat(101) }) + .expect(400); + + const stored = await PromptGroup.findById(testGroup._id).lean(); + expect(stored.category).toBe('security-test'); + }); + it('should allow updating allowed fields (name, category, oneliner)', async () => { const updateData = { name: 'Updated Group Name', diff --git a/packages/api/src/prompts/schemas.spec.ts b/packages/api/src/prompts/schemas.spec.ts index 2ba34e17f28..95646d1c1c4 100644 --- a/packages/api/src/prompts/schemas.spec.ts +++ b/packages/api/src/prompts/schemas.spec.ts @@ -1,5 +1,6 @@ import { updatePromptGroupSchema, + safeValidatePromptGroupCategory, validatePromptGroupUpdate, safeValidatePromptGroupUpdate, } from './schemas'; @@ -200,3 +201,27 @@ describe('safeValidatePromptGroupUpdate', () => { } }); }); + +describe('safeValidatePromptGroupCategory', () => { + it.each([['a'.repeat(101)], ['sys__x'], ['a\u0007b']])('should reject %j', (category) => { + expect(safeValidatePromptGroupCategory(category).success).toBe(false); + }); + + it('should accept an empty string', () => { + const result = safeValidatePromptGroupCategory(''); + expect(result.success && result.data).toBe(''); + }); + + it('should trim surrounding whitespace', () => { + const result = safeValidatePromptGroupCategory(' Onboarding '); + expect(result.success && result.data).toBe('Onboarding'); + }); + + it('should accept a 100 character category', () => { + expect(safeValidatePromptGroupCategory('a'.repeat(100)).success).toBe(true); + }); + + it('should reject a sys__ category on update', () => { + expect(safeValidatePromptGroupUpdate({ category: 'sys__x' }).success).toBe(false); + }); +}); diff --git a/packages/api/src/prompts/schemas.ts b/packages/api/src/prompts/schemas.ts index c9df44bfe7a..68c50ac5f76 100644 --- a/packages/api/src/prompts/schemas.ts +++ b/packages/api/src/prompts/schemas.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; -import { Constants } from 'librechat-data-provider'; +import { Constants, promptCategoryValueSchema } from 'librechat-data-provider'; + +export const promptGroupCategorySchema = z.union([z.literal(''), promptCategoryValueSchema]); /** * Schema for validating prompt group update payloads. @@ -13,7 +15,7 @@ export const updatePromptGroupSchema: z.ZodObject< /** Short description/oneliner for the prompt group */ oneliner: z.ZodOptional; /** Category for organizing prompt groups */ - category: z.ZodOptional; + category: z.ZodOptional; /** Command shortcut for the prompt group */ command: z.ZodNullable>; }, @@ -25,7 +27,7 @@ export const updatePromptGroupSchema: z.ZodObject< /** Short description/oneliner for the prompt group */ oneliner: z.string().max(500).optional(), /** Category for organizing prompt groups */ - category: z.string().max(100).optional(), + category: promptGroupCategorySchema.optional(), /** Command shortcut for the prompt group */ command: z .string() @@ -72,3 +74,7 @@ export function safeValidatePromptGroupUpdate(data: unknown): z.SafeParseReturnT > { return updatePromptGroupSchema.safeParse(data); } + +export function safeValidatePromptGroupCategory(category: unknown) { + return promptGroupCategorySchema.safeParse(category); +} From 7f38173128e69c4fcb7f2b8feab5af5912071777 Mon Sep 17 00:00:00 2001 From: TomasPalsson Date: Wed, 30 Sep 2026 17:14:28 +0000 Subject: [PATCH 06/17] feat: resolve prompt category labels, icons and colors from configured categories --- .../Prompts/utils/CategoryIcon.spec.tsx | 38 +++++++++++++- .../components/Prompts/utils/CategoryIcon.tsx | 39 +++++++++++++- .../Prompts/__tests__/useCategories.spec.tsx | 52 +++++++++++++++++++ client/src/hooks/Prompts/useCategories.tsx | 5 +- 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 client/src/hooks/Prompts/__tests__/useCategories.spec.tsx diff --git a/client/src/components/Prompts/utils/CategoryIcon.spec.tsx b/client/src/components/Prompts/utils/CategoryIcon.spec.tsx index 84594373fa1..16fcf121b14 100644 --- a/client/src/components/Prompts/utils/CategoryIcon.spec.tsx +++ b/client/src/components/Prompts/utils/CategoryIcon.spec.tsx @@ -1,7 +1,20 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { QueryKeys } from 'librechat-data-provider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { TCategory } from 'librechat-data-provider'; import CategoryIcon from './CategoryIcon'; +const renderIcon = (category: string, configured: TCategory[] = []) => { + const queryClient = new QueryClient(); + queryClient.setQueryData([QueryKeys.categories], configured); + return render( + + + , + ); +}; + const categoryColors = { code: 'text-series-5', misc: 'text-series-1', @@ -24,7 +37,7 @@ describe('CategoryIcon', () => { it.each(Object.entries(categoryColors))( 'uses the semantic series color for %s', (category, color) => { - const { container } = render(); + const { container } = renderIcon(category); const icon = container.querySelector('svg'); expect(icon).toHaveClass(color); @@ -35,8 +48,29 @@ describe('CategoryIcon', () => { ); it('uses secondary text for an unknown category', () => { - const { container } = render(); + const { container } = renderIcon('unknown'); expect(container.querySelector('svg')).toHaveClass('text-text-secondary'); }); + + it('uses the configured icon and color over the built-in maps', () => { + const { container } = renderIcon('hr-team', [ + { value: 'hr-team', label: 'HR', icon: 'users', color: 'series-7' }, + ]); + const icon = container.querySelector('svg'); + + expect(icon).toHaveClass('lucide-users', 'text-series-7'); + }); + + it('falls back to the file icon for an unknown value', () => { + const { container } = renderIcon('unknown'); + + expect(container.querySelector('svg')).toHaveClass('lucide-file-text', 'text-text-secondary'); + }); + + it('keeps the built-in icon and color when the config has no entry', () => { + const { container } = renderIcon('code', [{ value: 'other', label: 'Other' }]); + + expect(container.querySelector('svg')).toHaveClass('lucide-square-terminal', 'text-series-5'); + }); }); diff --git a/client/src/components/Prompts/utils/CategoryIcon.tsx b/client/src/components/Prompts/utils/CategoryIcon.tsx index 4c826123dcf..f167d529df9 100644 --- a/client/src/components/Prompts/utils/CategoryIcon.tsx +++ b/client/src/components/Prompts/utils/CategoryIcon.tsx @@ -14,8 +14,37 @@ import { Beaker as BeakerIcon, Settings as SettingsIcon, } from 'lucide-react'; +import type { PromptCategoryColor, PromptCategoryIcon } from 'librechat-data-provider'; +import { useGetCategories } from '~/data-provider'; import { cn } from '~/utils'; +const configIconMap: Record = { + dices: Dices, + box: BoxIcon, + 'file-text': FileText, + 'pen-line': PenLineIcon, + lightbulb: LightbulbIcon, + 'line-chart': LineChartIcon, + 'shopping-bag': ShoppingBagIcon, + 'plane-takeoff': PlaneTakeoffIcon, + 'graduation-cap': GraduationCapIcon, + 'terminal-square': TerminalSquareIcon, + users: UsersIcon, + beaker: BeakerIcon, + settings: SettingsIcon, +}; + +const configColorMap: Record = { + 'series-1': 'text-series-1', + 'series-2': 'text-series-2', + 'series-3': 'text-series-3', + 'series-4': 'text-series-4', + 'series-5': 'text-series-5', + 'series-6': 'text-series-6', + 'series-7': 'text-series-7', + 'series-8': 'text-series-8', +}; + const categoryIconMap: Record = { misc: BoxIcon, roleplay: Dices, @@ -59,7 +88,13 @@ export default function CategoryIcon({ category: string; className?: string; }) { - const IconComponent = categoryIconMap[category] ?? FileText; - const colorClass = categoryColorMap[category] ?? 'text-text-secondary'; + const { data: categories } = useGetCategories(); + const entry = categories?.find((c) => c.value === category); + const IconComponent = + (entry?.icon && configIconMap[entry.icon]) || categoryIconMap[category] || FileText; + const colorClass = + (entry?.color && configColorMap[entry.color]) || + categoryColorMap[category] || + 'text-text-secondary'; return