diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index c253c4f14b3..c2ad331eaac 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,11 +1,48 @@ const path = require('path'); const { loadYaml, redactConfigSecretMaps, createCustomConfigLoader } = require('@librechat/api'); +const { logger, runAsSystem } = 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; + 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 runAsSystem(() => syncCategories(customCategoriesList, enableDefaultCategories)); + } + } + + return customConfig; +} + +module.exports = loadCustomConfig; 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/api/server/utils/agentCategory.js b/api/server/utils/agentCategory.js new file mode 100644 index 00000000000..05a5a0f1c71 --- /dev/null +++ b/api/server/utils/agentCategory.js @@ -0,0 +1,126 @@ +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. + * @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...'); + + const dbCategories = await getAllCategories(); + logger.info(`Found ${dbCategories.length} categories in the database.`); + + 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)) { + 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)) { + 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}.`); + + 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, + rawValue: cat.value.toString().trim(), + label: optionalString(cat.label) || undefined, + description: optionalString(cat.description), + order: initialOrder + acc.length, + }); + return acc; + }, []); + + const configuredValues = new Set(preparedCategories.map((c) => c.normalizedValue)); + const categoriesToDelete = dbCategories.filter( + (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); + } + + for (const prepared of preparedCategories) { + const existing = dbCategories.find( + (cat) => normalizeValue(cat.value) === prepared.normalizedValue, + ); + + /* 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 || prepared.rawValue, + description: prepared.description ?? '', + isActive: true, + custom: true, + order: prepared.order, + }; + + 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/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 ddd55f2c1ba..dc2914d2f08 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -280,6 +280,25 @@ interface: roles: true marketplace: 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) feedback: true diff --git a/packages/data-provider/specs/config.spec.ts b/packages/data-provider/specs/config.spec.ts new file mode 100644 index 00000000000..1d32c0cb1d7 --- /dev/null +++ b/packages/data-provider/specs/config.spec.ts @@ -0,0 +1,62 @@ +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 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); + }); + + 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); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 8cb0a15ccaf..245e7d32064 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2456,6 +2456,20 @@ 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(), + label: z.string().optional(), + 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,