Skip to content

🏷️ feat: Configurable and User-Created Prompt Categories - #16574

Draft
TomasPalsson wants to merge 16 commits into
LibreChat-AI:devfrom
TomasPalsson:feat/configurable-prompt-categories
Draft

TomasPalsson wants to merge 16 commits into
LibreChat-AI:devfrom
TomasPalsson:feat/configurable-prompt-categories

Conversation

@TomasPalsson

Copy link
Copy Markdown
Contributor

Summary

The Prompt Library offers a fixed list of nine categories (Ideas, Travel, Learning, Writing, Shopping, Code, Misc., Roleplay, Finance) that lives in server code, so an organisation that wants categories matching its departments has to fork, and prompt authors cannot tag a prompt with their own word even though the stored category field is already free text. Related to #3353.

This adds a prompts.categories section to librechat.yaml, reusing the vocabulary of the agent-category proposal (#13309): enableDefaultCategories hides the built-ins, list adds entries (value, optional label that is either a com_ translation key or literal text, optional icon from the icons the Prompt Library already uses, optional color from series-1…series-8), and an entry whose value matches a built-in overrides it in place. allowCustom (default false) lets authors create a category from the prompt category selector and makes the list also carry the distinct custom values stored on prompts the requester can view. With no prompts section the endpoint returns exactly today's nine, in today's order.

Categories stay stateless: the list is resolved per request from config plus stored values, so there is no new collection or sync job, per-role/group/tenant admin overrides apply through the existing override merge, and removing a config entry only hides it. Prompt create and update now reject a category over 100 characters, starting with the reserved sys__ prefix, or containing control characters, with the existing 400 validation shape. Docs: LibreChat-AI/docs#793.

How it works

GET /api/categories   (requireJwtAuth → configMiddleware → handler from @librechat/api)
  allowCustom false → resolvePromptCategories(config, [])            # 0 DB reads
  allowCustom true  → getPromptGroupAccessContext(user)              # 1
                      getDistinctPromptGroupCategories(accessibleIds) # 2, sorted, ≤200
                      resolvePromptCategories(config, customValues)
  failure           → 500 { message: 'Failed to retrieve categories' }  (no error text)

Resolution order: built-ins in today's order (unless disabled) with matching list entries merged in place, then remaining list entries in yaml order, then custom values not already present (case-insensitive on value and label). /api/config exposes promptCategories.allowCustom so the client knows whether to offer "New category…".

packages/data-provider/src/config.ts        # schema, icons, colors, defaultPromptCategories
packages/data-schemas/src/methods/prompt.ts # getDistinctPromptGroupCategories
packages/api/src/prompts/categories.ts      # resolver, handler factory, startup helper
api/server/routes/categories.js             # wiring only
client/src/components/Prompts/fields/CategorySelector.tsx  # create option
client/src/components/Prompts/utils/CategoryIcon.tsx       # configured icon/color

Type of change

  • Feature
  • Documentation

Testing

Tested environments/configuration: production build (npm run build:client, node api/server/index.js), Chromium via Playwright, MongoDB 7, light and dark theme. Config A: enableDefaultCategories: false, allowCustom: true, entries hr (Human Resources, users, series-7), legal (Legal, beaker, series-3), code (Engineering). Config B: no prompts section.

  1. Config A: the selector and the sidebar filter list only Human Resources, Legal and Engineering.
  2. With the keyboard only (focus the selector, Enter, End, Enter, type, Enter) create "Onboarding"; typing sys__x disables the button with "Category names cannot start with sys__".
  3. Save the prompt: the filter lists "Onboarding" without a reload.
  4. Config B: the selector and filter show today's nine in today's order, and no "New category…" item.

Automated tests: packages/data-provider/specs/config.spec.ts, packages/api/src/prompts/categories.spec.ts, packages/api/src/prompts/schemas.spec.ts, packages/data-schemas/src/methods/prompt.spec.ts ("distinct categories", in-memory Mongo), packages/data-schemas/src/app/service.spec.ts, api/server/routes/categories.test.js (defaults unchanged, role override through the real override merge, no error leak, access scoped between two users, no reads when off), api/server/routes/prompts.test.js (category validation), client useCategories, CategoryIcon, CategorySelector (prompt and skill), FilterPrompts. npm run static-checks:full -- --against upstream/dev and npm run lighthouse (median LCP 3476 ms) pass.

Screenshots / recordings

The selector's "New category…" item and inline form are a new surface, so they have no "before". With no config the lists are unchanged from dev.

Screenshots (Config A in light and dark, the keyboard create flow, the filter after save, and Config B matching dev) are added below in a comment.

Risk / compatibility

  • No config means no change: the categories response is the same nine { label, value } objects as before, and allowCustom defaults to false, so no extra database reads.
  • allowCustom defaults to false per the "new levers reproduce today's behaviour" rule. If true fits Enhancement: [Prompt Library] Let users create their own categories #3353 better, it is a one-line flip.
  • Behaviour choices a reviewer may want to weigh: with defaults disabled, a list entry that reuses a built-in value but omits label shows the raw value rather than the built-in's translated label (a missing label renders the value). The 200 custom-value cap applies after a case-insensitive sort over the prompts the user can view, so if a user can see more than 200 distinct custom values, the ones late in the alphabet are left out. A whitespace-only category on update now returns 400, while '' still clears it. CategoryIcon reads the cached categories query without useCategories' access gate, because it only renders inside the Prompts UI.
  • Stored categories are never rewritten; removing a config entry only hides it. A prompt whose category is no longer listed shows the "Category" placeholder, as before, unless custom categories are allowed.

Copilot AI balanced review requested due to automatic review settings September 30, 2026 19:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@danny-avila

Copy link
Copy Markdown
Collaborator

Hi Tomas, I would like to review/merge some of your PRs soon

@danny-avila

Copy link
Copy Markdown
Collaborator

thanks for your patience, getting the dev branch up to date with our experimental changes first

@lia-by-librechat lia-by-librechat Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head ab7d8aa40c74e4af0cf38d5c430d25db73fb0c61. One P1 and four P2 findings below.

Source-level reproductions used the frozen-head functions with dependency boundaries stubbed. Local Jest and TypeScript checks were blocked by missing dependencies. CI tests, typechecks, builds, integration checks, Lighthouse, and runtime smoke passed; Static checks failed on class ordering in CategorySelector.tsx.

Comment on lines +85 to +91
const { accessibleIds } = await deps.getPromptGroupAccessContext({
userId: req.user?.id ?? '',
role: req.user?.role,
});
customValues = await deps.getDistinctPromptGroupCategories(accessibleIds);
}
res.status(200).send(resolvePromptCategories(config, customValues));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Apply current prompt-content policy to stored category discovery

With allowCustom: true, this exposes stored category text without consulting req.config.filters. category is a protected prompt field, and existing prompt reads reapply the current policy and suppress blocked group metadata. An older category matching a newly enabled filters.prompts.pii rule can therefore be hidden by prompt reads yet returned verbatim by /api/categories as both value and label. Apply the same content-policy boundary before exposing stored categories, and cover a category stored before the blocking policy was enabled.

Comment on lines +10 to +14
requireJwtAuth,
configMiddleware,
createGetPromptCategoriesHandler({
getPromptGroupAccessContext,
getDistinctPromptGroupCategories,

Copy link
Copy Markdown
Contributor

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. getPromptGroupAccessContext resolves 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.

Comment on lines +65 to +69
const seen = new Set(result.flatMap((c) => [normalize(c.value), normalize(c.label)]));
for (const value of customValues) {
const key = normalize(value);
if (seen.has(key)) {
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep category deduplication consistent with exact-match filtering

Create/update preserve case, and buildPromptGroupFilter uses exact category values. If two authors independently create Alpha and alpha before sharing their prompts, this keeps only one option; selecting it excludes prompts using the other spelling. Label-based deduplication also hides an existing People category when config contains { value: "hr", label: "People" }, but the remaining option filters for hr. Retain selectable stored identities or make filtering honor the same equivalence rules, without rewriting stored data.

Comment on lines +24 to +26
const fromEntry = ({ value, label, icon, color }: CategoryEntry): TCategory => ({
value,
label: label ?? value,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply schema trimming to runtime configured category values

The config loader validates with configSchema but returns the original object, and AppService copies config.prompts unchanged. A valid YAML entry with value: " hr " therefore reaches this function untrimmed. Selecting it saves hr because create/update validation trims categories, while the sidebar filters for " hr " and misses that prompt. When custom categories are enabled, normalized deduplication also suppresses the stored hr option. Apply schema transformations to runtime category entries, including overrides, before returning their values.

);

addGroupToAll(queryClient, group);
queryClient.invalidateQueries([QueryKeys.categories]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Invalidate categories on prompt-group deletion too

Now that categories are derived from stored groups, create/update invalidation alone is insufficient. Neither useDeletePromptGroup nor the response.promptGroup branch of useDeletePrompt invalidates this query. Deleting the only prompt using Onboarding, including deleting its final version and therefore its group, leaves Onboarding in the selector and sidebar filter. useGetCategories disables refetch on mount, focus, and reconnect, so it remains stale until another invalidating mutation or a reload. Invalidate categories on both group-deletion paths and cover deletion of the last categorized prompt.

@codegraph-librechat codegraph-librechat Bot added the 🗺️ Prompt Library codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) label Oct 1, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🗺️ Prompt Library codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants