From 112d2eddc3a4bc7f9ad788aea7c350b4e2fa94af Mon Sep 17 00:00:00 2001 From: Educg550 Date: Tue, 16 Sep 2025 13:57:02 -0300 Subject: [PATCH 1/7] feat: agent marketplace categories synced through librechat.yaml --- .../services/Config/loadCustomConfig.js | 33 +++++++- api/server/utils/agentCategory.js | 82 +++++++++++++++++++ librechat.example.yaml | 10 ++- 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 api/server/utils/agentCategory.js diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index c253c4f14b3..f144a54a496 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,11 +1,42 @@ const path = require('path'); const { loadYaml, redactConfigSecretMaps, createCustomConfigLoader } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); +const { syncCategories } = require('~/server/utils/agentCategory'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); -module.exports = createCustomConfigLoader({ +const loadConfig = createCustomConfigLoader({ loadLocal: loadYaml, defaultConfigPath, redactConfig: redactConfigSecretMaps, }); + +async function loadCustomConfig(printConfig = true, options = {}) { + const customConfig = await loadConfig(printConfig, options); + if (!customConfig) { + return customConfig; + } + + // Injecting custom marketplace categories + logger.info('Checking for custom marketplace categories to sync.'); + if (customConfig.interface?.marketplace?.use) { + logger.info('Marketplace is enabled in config.'); + const marketplaceConfig = customConfig.interface.marketplace; + if (marketplaceConfig.categories) { + logger.info('Marketplace categories configuration found.'); + // enableDefaultCategories should be set as a boolean, defaulting to true if not specified + const enableDefaultCategories = + typeof marketplaceConfig.categories.enableDefaultCategories === 'boolean' + ? marketplaceConfig.categories.enableDefaultCategories + : true; + const customCategoriesList = marketplaceConfig.categories.list || []; + logger.info(`Found ${customCategoriesList.length} custom categories to sync.`); + await syncCategories(customCategoriesList, enableDefaultCategories); + } + } + + return customConfig; +} + +module.exports = loadCustomConfig; diff --git a/api/server/utils/agentCategory.js b/api/server/utils/agentCategory.js new file mode 100644 index 00000000000..b9375fa6737 --- /dev/null +++ b/api/server/utils/agentCategory.js @@ -0,0 +1,82 @@ +const { logger } = require('@librechat/data-schemas'); +const { getAllCategories, createCategory, updateCategory, deleteCategory } = require('~/models'); + +/** + * Synchronizes custom agent categories with the database. + * @param {Array} customCategoriesList - List of custom categories to be synchronized. + * @param {boolean} enableDefaultCategories - Flag indicating whether to enable default categories. + */ +async function syncCategories(customCategoriesList, enableDefaultCategories) { + logger.info('Syncing custom agent categories...'); + + // Get all categories from DB + const dbCategories = await getAllCategories(); + logger.info(`Found ${dbCategories.length} categories in the database.`); + + logger.info( + `${enableDefaultCategories ? 'Enabling' : 'Disabling'} default agent categories as per configuration.`, + ); + for (const cat of dbCategories.filter((c) => !c.custom)) { + logger.info(`${enableDefaultCategories ? 'Enabling' : 'Disabling'} category: ${cat.value}`); + await updateCategory(cat.value, { isActive: enableDefaultCategories }); + } + + // Initial order takes the max value of existing default categories + 1 + const defaultCategoriesInDb = dbCategories.filter((cat) => !cat.custom); + const initialOrder = + defaultCategoriesInDb.reduce((max, cat) => Math.max(max, cat.order || 0), 0) + 1; + logger.info(`Current order for new custom categories starts at ${initialOrder}.`); + + // Inject order to custom categories + logger.info('Assigning order to custom categories.'); + customCategoriesList = customCategoriesList.map((cat, index) => ({ + ...cat, + order: initialOrder + index, + })); + + // Delete custom categories not in the config file + const customCategoryValues = new Set(customCategoriesList.map((cat) => cat.value.toLowerCase())); + const categoriesToDelete = dbCategories.filter( + (cat) => cat.custom && !customCategoryValues.has(cat.value.toLowerCase()), + ); + for (const cat of categoriesToDelete) { + logger.info(`Deleting custom category not in config: ${cat.value}`); + await deleteCategory(cat.value); + } + + // Sync custom categories + for (const category of customCategoriesList) { + let formattedCategory; + try { + formattedCategory = { + value: category.value.toLowerCase() || 'unnamed', + label: category.value || 'Unnamed', + description: category.description || '', + isActive: true, + custom: true, + order: category.order, + }; + } catch (error) { + logger.error('Error formatting category: ', category, error); + continue; + } + + // Check if category exists by value + const existing = dbCategories.find( + (cat) => cat.value.toLowerCase() === formattedCategory.value, + ); + if (existing) { + logger.info(`Updating category: ${formattedCategory.value}`); + await updateCategory(existing.value, formattedCategory); + } else { + logger.info(`Creating category: ${formattedCategory.value}`); + await createCategory(formattedCategory); + } + } + + logger.info('Custom categories synchronized successfully.'); +} + +module.exports = { + syncCategories, +}; diff --git a/librechat.example.yaml b/librechat.example.yaml index ddd55f2c1ba..2662f337410 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -279,7 +279,15 @@ interface: groups: true roles: true marketplace: - use: false + use: false + categories: + enableDefaultCategories: false + # list: + # - value: "Education" + # description: "Educational agents." + # - value: "Productivity" + # description: "Productivity agents." + fileCitations: true # Show the thumbs up/thumbs down feedback buttons on responses (default: true) feedback: true From 7a3579ffd344841414ccf5a2276f16f74a1da15a Mon Sep 17 00:00:00 2001 From: Educg550 Date: Wed, 22 Oct 2025 16:51:12 -0300 Subject: [PATCH 2/7] fix: update librechat.example.yaml, internal imports --- api/server/utils/agentCategory.js | 2 +- librechat.example.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/server/utils/agentCategory.js b/api/server/utils/agentCategory.js index b9375fa6737..1d40193a572 100644 --- a/api/server/utils/agentCategory.js +++ b/api/server/utils/agentCategory.js @@ -1,5 +1,4 @@ const { logger } = require('@librechat/data-schemas'); -const { getAllCategories, createCategory, updateCategory, deleteCategory } = require('~/models'); /** * Synchronizes custom agent categories with the database. @@ -7,6 +6,7 @@ const { getAllCategories, createCategory, updateCategory, deleteCategory } = req * @param {boolean} enableDefaultCategories - Flag indicating whether to enable default categories. */ async function syncCategories(customCategoriesList, enableDefaultCategories) { + const { getAllCategories, createCategory, updateCategory, deleteCategory } = require('~/models'); logger.info('Syncing custom agent categories...'); // Get all categories from DB diff --git a/librechat.example.yaml b/librechat.example.yaml index 2662f337410..c268958e5ae 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -281,7 +281,7 @@ interface: marketplace: use: false categories: - enableDefaultCategories: false + enableDefaultCategories: true # list: # - value: "Education" # description: "Educational agents." From b3db36946e81bb9b7631140ba91fab8a0b33434f Mon Sep 17 00:00:00 2001 From: Educg550 Date: Mon, 25 May 2026 16:56:33 -0300 Subject: [PATCH 3/7] fix: guards and YAML formatting for agent-category sync --- .../services/Config/loadCustomConfig.js | 10 ++- api/server/utils/agentCategory.js | 82 +++++++++++-------- librechat.example.yaml | 14 ++-- 3 files changed, 65 insertions(+), 41 deletions(-) diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index f144a54a496..033597f5089 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -30,8 +30,14 @@ async function loadCustomConfig(printConfig = true, options = {}) { typeof marketplaceConfig.categories.enableDefaultCategories === 'boolean' ? marketplaceConfig.categories.enableDefaultCategories : true; - const customCategoriesList = marketplaceConfig.categories.list || []; - logger.info(`Found ${customCategoriesList.length} custom categories to sync.`); + const customCategoriesList = marketplaceConfig.categories.list; + if (Array.isArray(customCategoriesList)) { + logger.info(`Found ${customCategoriesList.length} custom categories to sync.`); + } else { + logger.info( + 'No custom categories `list` provided; only default-category toggling will run.', + ); + } await syncCategories(customCategoriesList, enableDefaultCategories); } } diff --git a/api/server/utils/agentCategory.js b/api/server/utils/agentCategory.js index 1d40193a572..5d15eb34fb3 100644 --- a/api/server/utils/agentCategory.js +++ b/api/server/utils/agentCategory.js @@ -1,15 +1,18 @@ const { logger } = require('@librechat/data-schemas'); +function normalizeValue(raw) { + return (raw ?? '').toString().trim().toLowerCase(); +} + /** - * Synchronizes custom agent categories with the database. - * @param {Array} customCategoriesList - List of custom categories to be synchronized. - * @param {boolean} enableDefaultCategories - Flag indicating whether to enable default categories. + * Synchronizes agent categories with the database. + * @param {Array|undefined} customCategoriesList - Custom categories from config. When `undefined`, custom categories are left untouched (only defaults are toggled). An explicit empty array opts in to deleting all existing custom categories. + * @param {boolean} enableDefaultCategories - Whether default categories should be active. */ async function syncCategories(customCategoriesList, enableDefaultCategories) { const { getAllCategories, createCategory, updateCategory, deleteCategory } = require('~/models'); logger.info('Syncing custom agent categories...'); - // Get all categories from DB const dbCategories = await getAllCategories(); logger.info(`Found ${dbCategories.length} categories in the database.`); @@ -21,50 +24,65 @@ async function syncCategories(customCategoriesList, enableDefaultCategories) { await updateCategory(cat.value, { isActive: enableDefaultCategories }); } - // Initial order takes the max value of existing default categories + 1 + if (!Array.isArray(customCategoriesList)) { + logger.info( + 'No custom categories list provided; leaving existing custom categories untouched.', + ); + return; + } + const defaultCategoriesInDb = dbCategories.filter((cat) => !cat.custom); const initialOrder = defaultCategoriesInDb.reduce((max, cat) => Math.max(max, cat.order || 0), 0) + 1; logger.info(`Current order for new custom categories starts at ${initialOrder}.`); - // Inject order to custom categories - logger.info('Assigning order to custom categories.'); - customCategoriesList = customCategoriesList.map((cat, index) => ({ - ...cat, - order: initialOrder + index, - })); + const preparedCategories = customCategoriesList.reduce((acc, cat, index) => { + const normalizedValue = normalizeValue(cat?.value); + if (!normalizedValue) { + logger.warn( + `Skipping invalid custom category entry at index ${index}: missing or empty value.`, + ); + return acc; + } + acc.push({ + normalizedValue, + label: cat.value.toString().trim(), + description: typeof cat.description === 'string' ? cat.description : '', + order: initialOrder + acc.length, + }); + return acc; + }, []); - // Delete custom categories not in the config file - const customCategoryValues = new Set(customCategoriesList.map((cat) => cat.value.toLowerCase())); + const configuredValues = new Set(preparedCategories.map((c) => c.normalizedValue)); const categoriesToDelete = dbCategories.filter( - (cat) => cat.custom && !customCategoryValues.has(cat.value.toLowerCase()), + (cat) => cat.custom && !configuredValues.has(normalizeValue(cat.value)), ); for (const cat of categoriesToDelete) { logger.info(`Deleting custom category not in config: ${cat.value}`); await deleteCategory(cat.value); } - // Sync custom categories - for (const category of customCategoriesList) { - let formattedCategory; - try { - formattedCategory = { - value: category.value.toLowerCase() || 'unnamed', - label: category.value || 'Unnamed', - description: category.description || '', - isActive: true, - custom: true, - order: category.order, - }; - } catch (error) { - logger.error('Error formatting category: ', category, error); + for (const prepared of preparedCategories) { + const existing = dbCategories.find( + (cat) => normalizeValue(cat.value) === prepared.normalizedValue, + ); + + if (existing && existing.custom === false) { + logger.warn( + `Skipping custom category '${prepared.label}': value collides with an existing default category.`, + ); continue; } - // Check if category exists by value - const existing = dbCategories.find( - (cat) => cat.value.toLowerCase() === formattedCategory.value, - ); + const formattedCategory = { + value: prepared.normalizedValue, + label: prepared.label, + description: prepared.description, + isActive: true, + custom: true, + order: prepared.order, + }; + if (existing) { logger.info(`Updating category: ${formattedCategory.value}`); await updateCategory(existing.value, formattedCategory); diff --git a/librechat.example.yaml b/librechat.example.yaml index c268958e5ae..f06ccbc5308 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -279,14 +279,14 @@ interface: groups: true roles: true marketplace: - use: false + use: false categories: - enableDefaultCategories: true - # list: - # - value: "Education" - # description: "Educational agents." - # - value: "Productivity" - # description: "Productivity agents." + enableDefaultCategories: true + # list: + # - value: "Education" + # description: "Educational agents." + # - value: "Productivity" + # description: "Productivity agents." fileCitations: true # Show the thumbs up/thumbs down feedback buttons on responses (default: true) From e27e7ad047fb2c18e0d215f17587680df20d0857 Mon Sep 17 00:00:00 2001 From: Educg550 Date: Mon, 22 Jun 2026 18:55:40 -0300 Subject: [PATCH 4/7] fix: running db queries system-wide to avoid multi-tenant collision --- api/server/services/Config/loadCustomConfig.js | 4 ++-- packages/data-provider/src/config.ts | 13 +++++++++++++ packages/data-schemas/src/methods/agentCategory.ts | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 033597f5089..c2ad331eaac 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,6 +1,6 @@ const path = require('path'); const { loadYaml, redactConfigSecretMaps, createCustomConfigLoader } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); const { syncCategories } = require('~/server/utils/agentCategory'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); @@ -38,7 +38,7 @@ async function loadCustomConfig(printConfig = true, options = {}) { 'No custom categories `list` provided; only default-category toggling will run.', ); } - await syncCategories(customCategoriesList, enableDefaultCategories); + await runAsSystem(() => syncCategories(customCategoriesList, enableDefaultCategories)); } } diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 8cb0a15ccaf..127b12403ff 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2456,6 +2456,19 @@ export const interfaceSchema = z * marketplace request; the count is also how many automatic attempts there are. * An empty array leaves only the manual Retry. Omit for LibreChat's sequence. */ retryDelaysMs: z.array(z.number().int().min(0).max(600_000)).max(20).optional(), + categories: z + .object({ + enableDefaultCategories: z.boolean().optional(), + list: z + .array( + z.object({ + value: z.string(), + description: z.string().optional(), + }), + ) + .optional(), + }) + .optional(), }) .optional(), fileSearch: z.boolean().optional(), diff --git a/packages/data-schemas/src/methods/agentCategory.ts b/packages/data-schemas/src/methods/agentCategory.ts index cb26923e238..1d2c9881384 100644 --- a/packages/data-schemas/src/methods/agentCategory.ts +++ b/packages/data-schemas/src/methods/agentCategory.ts @@ -1,6 +1,7 @@ import type { Model, Types } from 'mongoose'; import type { IAgentCategory } from '~/types'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; +import { runAsSystem } from '~/config/tenantContext'; export function createAgentCategoryMethods(mongoose: typeof import('mongoose')): { getActiveCategories: () => Promise; @@ -50,7 +51,7 @@ export function createAgentCategoryMethods(mongoose: typeof import('mongoose')): ]); const countMap = new Map(categoryCounts.map((c) => [c._id, c.count])); - const categories = await getActiveCategories(); + const categories = await runAsSystem(() => getActiveCategories()); return categories.map((category) => ({ ...category, From 693b905a1a8de74f43d22039ad53ba1e19072274 Mon Sep 17 00:00:00 2001 From: Educg550 Date: Tue, 23 Jun 2026 15:39:58 -0300 Subject: [PATCH 5/7] tests: system run for syncCategories, YAML safe parsing --- .../services/Config/loadCustomConfig.spec.js | 35 ++++++++++++++- packages/data-provider/specs/config.spec.ts | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/data-provider/specs/config.spec.ts diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index f4e827590fa..42aef4f416f 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -53,12 +53,18 @@ jest.mock('@librechat/data-schemas', () => { debug: jest.fn(), error: jest.fn(), }, + runAsSystem: jest.fn((fn) => fn()), }; }); +jest.mock('~/server/utils/agentCategory', () => ({ + syncCategories: jest.fn(), +})); + const axios = require('axios'); const { loadYaml } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); +const { syncCategories } = require('~/server/utils/agentCategory'); const { ReasoningParameterFormat, ReasoningResponseKey } = require('librechat-data-provider'); const loadCustomConfig = require('./loadCustomConfig'); @@ -121,6 +127,33 @@ describe('loadCustomConfig', () => { expect(result).toEqual(mockConfig); }); + it('runs marketplace category sync within a system tenant context', async () => { + runAsSystem.mockImplementation((fn) => fn()); + const config = { + version: '1.0', + cache: true, + interface: { + marketplace: { + use: true, + categories: { + enableDefaultCategories: true, + list: [{ value: 'Education', description: 'Educational agents.' }], + }, + }, + }, + }; + process.env.CONFIG_PATH = 'marketplaceConfig.yaml'; + loadYaml.mockReturnValueOnce(config); + + await loadCustomConfig(); + + expect(runAsSystem).toHaveBeenCalledTimes(1); + expect(syncCategories).toHaveBeenCalledWith( + [{ value: 'Education', description: 'Educational agents.' }], + true, + ); + }); + it('should return null and log if config schema validation fails', async () => { const invalidConfig = { invalidField: true }; process.env.CONFIG_PATH = 'invalidConfig.yaml'; diff --git a/packages/data-provider/specs/config.spec.ts b/packages/data-provider/specs/config.spec.ts new file mode 100644 index 00000000000..9d4ca432bd7 --- /dev/null +++ b/packages/data-provider/specs/config.spec.ts @@ -0,0 +1,43 @@ +import { configSchema } from '../src/config'; + +describe('configSchema — interface.marketplace.categories', () => { + const withCategories = (categories: unknown) => ({ + version: '1.2.8', + interface: { marketplace: { use: true, categories } }, + }); + + it('accepts a valid categories block with an ordered list', () => { + const result = configSchema.safeParse( + withCategories({ + enableDefaultCategories: true, + list: [ + { value: 'Education', description: 'Educational agents.' }, + { value: 'Productivity' }, + ], + }), + ); + expect(result.success).toBe(true); + }); + + it('accepts categories with only enableDefaultCategories (no list)', () => { + const result = configSchema.safeParse(withCategories({ enableDefaultCategories: false })); + expect(result.success).toBe(true); + }); + + it('rejects a list item missing the required `value`', () => { + const result = configSchema.safeParse( + withCategories({ list: [{ description: 'no value here' }] }), + ); + expect(result.success).toBe(false); + }); + + it('rejects a non-array `list`', () => { + const result = configSchema.safeParse(withCategories({ list: 'Education' })); + expect(result.success).toBe(false); + }); + + it('rejects a non-boolean `enableDefaultCategories`', () => { + const result = configSchema.safeParse(withCategories({ enableDefaultCategories: 'yes' })); + expect(result.success).toBe(false); + }); +}); From 1d86451fe55b277384c10a7f9e55b090c77ac353 Mon Sep 17 00:00:00 2001 From: Educg550 Date: Tue, 23 Jun 2026 15:48:36 -0300 Subject: [PATCH 6/7] docs: update categories example --- librechat.example.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/librechat.example.yaml b/librechat.example.yaml index f06ccbc5308..b7a09f12910 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -280,13 +280,14 @@ interface: roles: true marketplace: use: false + # categories is only applied when marketplace is enabled above categories: enableDefaultCategories: true - # list: - # - value: "Education" - # description: "Educational agents." - # - value: "Productivity" - # description: "Productivity agents." + list: + - value: "Education" + description: "Educational agents." + - value: "Productivity" + description: "Productivity agents." fileCitations: true # Show the thumbs up/thumbs down feedback buttons on responses (default: true) From 2a620e5e9216eb57eee24c276ed11de062a65a04 Mon Sep 17 00:00:00 2001 From: Educg550 Date: Thu, 6 Aug 2026 16:57:30 -0300 Subject: [PATCH 7/7] feat: agent category synchronization with localization and configuration support --- api/server/utils/agentCategory.js | 46 ++++- api/server/utils/agentCategory.spec.js | 197 ++++++++++++++++++++ librechat.example.yaml | 10 + packages/data-provider/specs/config.spec.ts | 19 ++ packages/data-provider/src/config.ts | 1 + 5 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 api/server/utils/agentCategory.spec.js diff --git a/api/server/utils/agentCategory.js b/api/server/utils/agentCategory.js index 5d15eb34fb3..05a5a0f1c71 100644 --- a/api/server/utils/agentCategory.js +++ b/api/server/utils/agentCategory.js @@ -1,9 +1,18 @@ const { logger } = require('@librechat/data-schemas'); +/** Mirrors the hardcoded agent fallback in `data-schemas` (agent schema default and `createAgent`). + * It must never be deactivated, or agents created without a category become unreachable from + * every marketplace tab except "All". */ +const FALLBACK_CATEGORY = 'general'; + function normalizeValue(raw) { return (raw ?? '').toString().trim().toLowerCase(); } +function optionalString(raw) { + return typeof raw === 'string' ? raw.trim() : undefined; +} + /** * Synchronizes agent categories with the database. * @param {Array|undefined} customCategoriesList - Custom categories from config. When `undefined`, custom categories are left untouched (only defaults are toggled). An explicit empty array opts in to deleting all existing custom categories. @@ -19,9 +28,15 @@ async function syncCategories(customCategoriesList, enableDefaultCategories) { logger.info( `${enableDefaultCategories ? 'Enabling' : 'Disabling'} default agent categories as per configuration.`, ); + if (!enableDefaultCategories) { + logger.warn( + `Keeping the '${FALLBACK_CATEGORY}' category active: it is the fallback assigned to agents created without a category. Declare it in the categories list to change its label or description.`, + ); + } for (const cat of dbCategories.filter((c) => !c.custom)) { - logger.info(`${enableDefaultCategories ? 'Enabling' : 'Disabling'} category: ${cat.value}`); - await updateCategory(cat.value, { isActive: enableDefaultCategories }); + const isActive = cat.value === FALLBACK_CATEGORY ? true : enableDefaultCategories; + logger.info(`${isActive ? 'Enabling' : 'Disabling'} category: ${cat.value}`); + await updateCategory(cat.value, { isActive }); } if (!Array.isArray(customCategoriesList)) { @@ -46,8 +61,9 @@ async function syncCategories(customCategoriesList, enableDefaultCategories) { } acc.push({ normalizedValue, - label: cat.value.toString().trim(), - description: typeof cat.description === 'string' ? cat.description : '', + rawValue: cat.value.toString().trim(), + label: optionalString(cat.label) || undefined, + description: optionalString(cat.description), order: initialOrder + acc.length, }); return acc; @@ -67,17 +83,27 @@ async function syncCategories(customCategoriesList, enableDefaultCategories) { (cat) => normalizeValue(cat.value) === prepared.normalizedValue, ); - if (existing && existing.custom === false) { - logger.warn( - `Skipping custom category '${prepared.label}': value collides with an existing default category.`, - ); + /* Default categories keep `custom: false` when overridden, so the deletion pass above (which + * only targets custom rows) can never remove them, and `ensureDefaultCategories` keeps + * managing their localization keys. Only explicitly provided fields are applied — otherwise + * re-declaring a default purely to reactivate it would clobber its `com_` label. */ + if (existing && !existing.custom) { + const overrides = { isActive: true }; + if (prepared.label) { + overrides.label = prepared.label; + } + if (prepared.description !== undefined) { + overrides.description = prepared.description; + } + logger.info(`Overriding default category: ${existing.value}`); + await updateCategory(existing.value, overrides); continue; } const formattedCategory = { value: prepared.normalizedValue, - label: prepared.label, - description: prepared.description, + label: prepared.label || prepared.rawValue, + description: prepared.description ?? '', isActive: true, custom: true, order: prepared.order, diff --git a/api/server/utils/agentCategory.spec.js b/api/server/utils/agentCategory.spec.js new file mode 100644 index 00000000000..01a1609200c --- /dev/null +++ b/api/server/utils/agentCategory.spec.js @@ -0,0 +1,197 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { createModels, runAsSystem } = require('@librechat/data-schemas'); + +/** `~/models` pulls in `getLogStores`, which builds Keyv/Mongo/Redis stores at module scope. */ +jest.mock('~/cache/getLogStores', () => + jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + })), +); + +const { syncCategories } = require('~/server/utils/agentCategory'); + +const DEFAULT_VALUES = ['general', 'hr', 'rd', 'finance', 'it', 'sales', 'aftersales']; + +let mongoServer; +let AgentCategory; +let models; + +/** Mirrors the production call site in `loadCustomConfig`, which wraps the sync in a system context. */ +const sync = (list, enableDefaultCategories) => + runAsSystem(() => syncCategories(list, enableDefaultCategories)); + +const seedDefaults = () => runAsSystem(() => models.ensureDefaultCategories()); + +async function categoriesByValue() { + const rows = await AgentCategory.find({}).lean(); + return new Map(rows.map((row) => [row.value, row])); +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + AgentCategory = mongoose.models.AgentCategory; + models = require('~/models'); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await AgentCategory.deleteMany({}); + await seedDefaults(); +}); + +describe('syncCategories', () => { + it('seeds the expected default categories before each case', async () => { + const categories = await categoriesByValue(); + expect([...categories.keys()].sort()).toEqual([...DEFAULT_VALUES].sort()); + expect(categories.get('general').label).toBe('com_agents_category_general'); + }); + + describe('custom category labels', () => { + it('falls back to the value as typed when no label is given', async () => { + await sync([{ value: ' Education ' }], true); + + expect((await categoriesByValue()).get('education')).toMatchObject({ + label: 'Education', + description: '', + custom: true, + isActive: true, + }); + }); + + it('stores a translation key verbatim so the client can localize it', async () => { + await sync( + [ + { + value: 'education', + label: 'com_agents_category_education', + description: 'com_agents_category_education_description', + }, + ], + true, + ); + + expect((await categoriesByValue()).get('education')).toMatchObject({ + label: 'com_agents_category_education', + description: 'com_agents_category_education_description', + custom: true, + }); + }); + + it('falls back to the value when the label is an empty string', async () => { + await sync([{ value: 'Education', label: ' ' }], true); + + expect((await categoriesByValue()).get('education').label).toBe('Education'); + }); + }); + + describe('the general fallback category', () => { + it('stays active when default categories are disabled', async () => { + await sync(undefined, false); + + const categories = await categoriesByValue(); + expect(categories.get('general').isActive).toBe(true); + for (const value of DEFAULT_VALUES.filter((v) => v !== 'general')) { + expect(categories.get(value).isActive).toBe(false); + } + }); + + it('is reactivated when a previous run left it disabled', async () => { + await AgentCategory.updateOne({ value: 'general' }, { $set: { isActive: false } }); + + await sync(undefined, false); + + expect((await categoriesByValue()).get('general').isActive).toBe(true); + }); + + it('keeps its localized label when re-declared without one', async () => { + await sync([{ value: 'general' }], false); + + expect((await categoriesByValue()).get('general')).toMatchObject({ + label: 'com_agents_category_general', + description: 'com_agents_category_general_description', + custom: false, + isActive: true, + }); + }); + }); + + describe('overriding default categories', () => { + it('applies the label without converting the row to a custom category', async () => { + const before = (await categoriesByValue()).get('aftersales'); + + await sync([{ value: 'aftersales', label: 'Customer Success' }], true); + + const after = (await categoriesByValue()).get('aftersales'); + expect(after).toMatchObject({ + label: 'Customer Success', + custom: false, + isActive: true, + }); + expect(after.order).toBe(before.order); + }); + + it('applies an explicitly empty description', async () => { + await sync([{ value: 'aftersales', description: '' }], true); + + expect((await categoriesByValue()).get('aftersales').description).toBe(''); + }); + + it('reactivates an explicitly declared default even when defaults are disabled', async () => { + await sync([{ value: 'hr', label: 'People' }], false); + + const categories = await categoriesByValue(); + expect(categories.get('hr')).toMatchObject({ + label: 'People', + isActive: true, + custom: false, + }); + expect(categories.get('rd').isActive).toBe(false); + }); + + it('survives being removed from the config, and is restored on the next boot', async () => { + await sync([{ value: 'aftersales', label: 'Customer Success' }], true); + await sync([], true); + + const afterRemoval = (await categoriesByValue()).get('aftersales'); + expect(afterRemoval).toMatchObject({ label: 'Customer Success', custom: false }); + + await seedDefaults(); + + expect((await categoriesByValue()).get('aftersales').label).toBe( + 'com_agents_category_aftersales', + ); + }); + }); + + describe('custom category lifecycle', () => { + it('deletes custom categories that are no longer in the config', async () => { + await sync([{ value: 'education' }], true); + await sync([], true); + + expect((await categoriesByValue()).has('education')).toBe(false); + }); + + it('leaves custom categories untouched when no list is provided', async () => { + await sync([{ value: 'education' }], true); + await sync(undefined, true); + + expect((await categoriesByValue()).has('education')).toBe(true); + }); + + it('skips entries with a missing or empty value', async () => { + await sync([{ value: ' ' }, { description: 'no value' }, { value: 'Education' }], true); + + const categories = await categoriesByValue(); + expect([...categories.keys()].sort()).toEqual([...DEFAULT_VALUES, 'education'].sort()); + }); + }); +}); diff --git a/librechat.example.yaml b/librechat.example.yaml index b7a09f12910..dc2914d2f08 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -282,12 +282,22 @@ interface: use: false # categories is only applied when marketplace is enabled above categories: + # When false, the built-in categories are hidden. `general` always stays + # active because it is the fallback category assigned to new agents. enableDefaultCategories: true list: + # `label` and `description` are optional and fall back to `value`. + # A value starting with `com_` is resolved as a UI translation key and + # must exist in the bundled locale files, otherwise the key itself is + # displayed. Omit `label` to show `value` exactly as typed. - value: "Education" description: "Educational agents." - value: "Productivity" description: "Productivity agents." + # Declaring a built-in category overrides its label and description, and + # keeps it visible even when enableDefaultCategories is false. + - value: "aftersales" + label: "Customer Success" fileCitations: true # Show the thumbs up/thumbs down feedback buttons on responses (default: true) diff --git a/packages/data-provider/specs/config.spec.ts b/packages/data-provider/specs/config.spec.ts index 9d4ca432bd7..1d32c0cb1d7 100644 --- a/packages/data-provider/specs/config.spec.ts +++ b/packages/data-provider/specs/config.spec.ts @@ -19,6 +19,25 @@ describe('configSchema — interface.marketplace.categories', () => { expect(result.success).toBe(true); }); + it('accepts an optional `label` used as a translation key', () => { + const result = configSchema.safeParse( + withCategories({ + list: [ + { value: 'education', label: 'com_agents_category_education' }, + { value: 'aftersales', label: 'Customer Success' }, + ], + }), + ); + expect(result.success).toBe(true); + }); + + it('rejects a non-string `label`', () => { + const result = configSchema.safeParse( + withCategories({ list: [{ value: 'education', label: 42 }] }), + ); + expect(result.success).toBe(false); + }); + it('accepts categories with only enableDefaultCategories (no list)', () => { const result = configSchema.safeParse(withCategories({ enableDefaultCategories: false })); expect(result.success).toBe(true); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 127b12403ff..245e7d32064 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2463,6 +2463,7 @@ export const interfaceSchema = z .array( z.object({ value: z.string(), + label: z.string().optional(), description: z.string().optional(), }), )