From d5cccb5f18e8b776d0f07ec6e635080760c5700d Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:12:31 +0200 Subject: [PATCH] feat: add favorites for marketplace tools, MCP servers, and skills Reintroduce the favorite star from the old skill picker, generalized to every marketplace item kind except per-agent actions. Cards in the Tool Library and Skills dialogs get a hover-revealed star (always visible once favorited), and the existing Favorites views in both dialogs now filter to starred items. Favorites persist in a dedicated ToolFavorite collection, one document per (user, itemType, itemId) with a unique compound index, exposed through atomic per-item PUT/DELETE endpoints under /api/user/settings/favorites/ tools. Per-item writes are idempotent and race-free across tabs/devices (the unique index backstops concurrent toggles), reads are a single index-backed query capped at 100 favorites per user, and the client keeps React Query as the source of truth with optimistic updates. Handlers live in @librechat/api with a thin route wrapper; methods follow the data-schemas factory pattern with tenant isolation. The favorites filter now matches on compound kind:id keys instead of bare ids, closing a cross-kind collision where a tool and a skill sharing an id would both match. The skill-favorites data-service stubs and the reserved TUserFavorite.skillId field are replaced by the new tool-favorites service. --- api/server/routes/settings.js | 15 ++ .../Agents/Tools/MarketplaceCatalog.tsx | 7 + .../SidePanel/Agents/Tools/SkillsDialog.tsx | 38 ++-- .../SidePanel/Agents/Tools/ToolCard.tsx | 75 ++++++-- .../Agents/Tools/ToolsMarketplaceDialog.tsx | 26 +-- .../Agents/Tools/__tests__/ToolCard.spec.tsx | 71 +++++++ .../__tests__/ToolsMarketplaceDialog.spec.tsx | 36 +++- .../Tools/items/__tests__/filtering.spec.ts | 48 ++++- .../SidePanel/Agents/Tools/items/filtering.ts | 9 +- client/src/data-provider/Favorites.ts | 66 ++++--- .../hooks/__tests__/useToolFavorites.spec.tsx | 117 ++++++++++++ client/src/hooks/index.ts | 1 + client/src/hooks/useToolFavorites.ts | 90 +++++++++ client/src/locales/en/translation.json | 1 + client/src/utils/favoritesError.ts | 10 +- packages/api/src/favorites/handlers.spec.ts | 178 ++++++++++++++++++ packages/api/src/favorites/handlers.ts | 126 +++++++++++++ packages/api/src/favorites/index.ts | 1 + packages/api/src/index.ts | 1 + packages/data-provider/src/api-endpoints.ts | 5 + packages/data-provider/src/data-service.ts | 18 +- packages/data-provider/src/keys.ts | 4 +- packages/data-provider/src/types/queries.ts | 14 +- packages/data-schemas/src/index.ts | 2 + .../data-schemas/src/methods/favorite.spec.ts | 153 +++++++++++++++ packages/data-schemas/src/methods/favorite.ts | 90 +++++++++ packages/data-schemas/src/methods/index.ts | 10 + packages/data-schemas/src/models/favorite.ts | 12 ++ packages/data-schemas/src/models/index.ts | 3 + packages/data-schemas/src/schema/favorite.ts | 40 ++++ packages/data-schemas/src/schema/index.ts | 1 + packages/data-schemas/src/types/favorite.ts | 35 ++++ packages/data-schemas/src/types/index.ts | 1 + 33 files changed, 1190 insertions(+), 114 deletions(-) create mode 100644 client/src/hooks/__tests__/useToolFavorites.spec.tsx create mode 100644 client/src/hooks/useToolFavorites.ts create mode 100644 packages/api/src/favorites/handlers.spec.ts create mode 100644 packages/api/src/favorites/handlers.ts create mode 100644 packages/api/src/favorites/index.ts create mode 100644 packages/data-schemas/src/methods/favorite.spec.ts create mode 100644 packages/data-schemas/src/methods/favorite.ts create mode 100644 packages/data-schemas/src/models/favorite.ts create mode 100644 packages/data-schemas/src/schema/favorite.ts create mode 100644 packages/data-schemas/src/types/favorite.ts diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js index c6b7c84b2c..38b353d560 100644 --- a/api/server/routes/settings.js +++ b/api/server/routes/settings.js @@ -1,4 +1,5 @@ const express = require('express'); +const { createToolFavoritesHandlers } = require('@librechat/api'); const { updateFavoritesController, getFavoritesController, @@ -8,9 +9,23 @@ const { updateSkillStatesController, } = require('~/server/controllers/SkillStatesController'); const { requireJwtAuth } = require('~/server/middleware'); +const { getToolFavorites, addToolFavorite, removeToolFavorite } = require('~/models'); const router = express.Router(); +const toolFavorites = createToolFavoritesHandlers({ + getToolFavorites, + addToolFavorite, + removeToolFavorite, +}); + +router.get('/favorites/tools', requireJwtAuth, toolFavorites.listToolFavorites); +router.put('/favorites/tools/:itemType/:itemId', requireJwtAuth, toolFavorites.addToolFavorite); +router.delete( + '/favorites/tools/:itemType/:itemId', + requireJwtAuth, + toolFavorites.removeToolFavorite, +); router.get('/favorites', requireJwtAuth, getFavoritesController); router.post('/favorites', requireJwtAuth, updateFavoritesController); router.get('/skills/active', requireJwtAuth, getSkillStatesController); diff --git a/client/src/components/SidePanel/Agents/Tools/MarketplaceCatalog.tsx b/client/src/components/SidePanel/Agents/Tools/MarketplaceCatalog.tsx index 8fbd4ab6ac..5d719984e1 100644 --- a/client/src/components/SidePanel/Agents/Tools/MarketplaceCatalog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/MarketplaceCatalog.tsx @@ -16,6 +16,9 @@ interface MarketplaceCatalogProps { view?: View; isLoadingSkills?: boolean; skillsInView?: boolean; + /** Compound `kind:id` keys of the user's favorited items. */ + favoriteKeys?: Set; + onToggleFavorite?: (item: AgentItem) => void; /** Overrides the default per-view empty message (e.g. "No skills found"). */ emptyKey?: TranslationKeys; /** Accessible label for the grid; defaults to the marketplace label. */ @@ -54,6 +57,8 @@ export default function MarketplaceCatalog({ view = 'marketplace', isLoadingSkills = false, skillsInView = false, + favoriteKeys, + onToggleFavorite, emptyKey, ariaLabel, }: MarketplaceCatalogProps) { @@ -84,6 +89,8 @@ export default function MarketplaceCatalog({ selected={selectedIds.has(itemKey(item))} onToggle={onToggle} onConfigure={onConfigure} + isFavorited={favoriteKeys?.has(itemKey(item)) ?? false} + onToggleFavorite={onToggleFavorite} /> ))} diff --git a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx index 9c425fa05c..03b16d4e36 100644 --- a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx @@ -16,14 +16,10 @@ import type { TranslationKeys } from '~/hooks/useLocalize'; import type { CategoryOption } from './CategoryFilter'; import type { AgentItem } from './items/types'; import type { AgentForm } from '~/common'; -import { - useListSkillsQuery, - useGetFavoritesQuery, - useGetSkillFavoritesQuery, -} from '~/data-provider'; -import { useLocalize, useHasAccess, useAuthContext } from '~/hooks'; +import { useLocalize, useHasAccess, useAuthContext, useToolFavorites } from '~/hooks'; import { CreateSkillDialog } from '~/components/Skills/dialogs'; import MarketplaceCatalog from './MarketplaceCatalog'; +import { useListSkillsQuery } from '~/data-provider'; import { CategoryIcon } from '~/components/Prompts'; import { buildSkillItems } from './items/catalog'; import ItemDialog from './ItemDialog/ItemDialog'; @@ -62,24 +58,7 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial { limit: 100 }, { enabled: hasSkillsAccess }, ); - const { data: favorites } = useGetFavoritesQuery(); - const { data: skillFavorites } = useGetSkillFavoritesQuery(); - - /** Phase 2 (favorites backend): skill favorites resolve from a stubbed - * data-service route and no UI toggles them yet, so the "Favorites" view - * stays empty until the backend route and a star affordance land. */ - const favoritedIds = useMemo(() => { - const ids = new Set(); - for (const favorite of favorites ?? []) { - if (favorite.skillId != null) { - ids.add(favorite.skillId); - } - } - for (const skillId of skillFavorites ?? []) { - ids.add(skillId); - } - return ids; - }, [favorites, skillFavorites]); + const { favoriteKeys, toggle: toggleFavorite } = useToolFavorites(); const skillsField = useWatch({ control, name: 'skills' }); const selectedIds = useMemo( @@ -120,8 +99,13 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial }, [catalog]); const filtered = useMemo( - () => applyFilter(catalog, { search, kind: 'skill', category, view }, { favoritedIds }), - [catalog, search, category, view, favoritedIds], + () => + applyFilter( + catalog, + { search, kind: 'skill', category, view }, + { favoritedIds: favoriteKeys }, + ), + [catalog, search, category, view, favoriteKeys], ); const handleSkillCreated = useCallback( @@ -226,6 +210,8 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial view={view} isLoadingSkills={isLoadingSkills} skillsInView={view !== 'mine'} + favoriteKeys={favoriteKeys} + onToggleFavorite={toggleFavorite} emptyKey={emptyKey} ariaLabel={localize('com_ui_skills')} /> diff --git a/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx b/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx index 0244fffe33..f5864ec733 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx @@ -1,5 +1,5 @@ import { memo, useState } from 'react'; -import { BadgeCheck, Check, Globe, Info, Settings, User } from 'lucide-react'; +import { BadgeCheck, Check, Globe, Info, Settings, Star, User } from 'lucide-react'; import type { TranslationKeys } from '~/hooks/useLocalize'; import type { AgentItem } from './items/types'; import { hasConfigurableSettings } from './items/configurable'; @@ -12,6 +12,8 @@ interface ToolCardProps { selected: boolean; onToggle: (item: AgentItem) => void; onConfigure?: (item: AgentItem) => void; + isFavorited?: boolean; + onToggleFavorite?: (item: AgentItem) => void; } function useDisplayStrings(item: AgentItem): { name: string; description: string } { @@ -76,12 +78,20 @@ function ItemIconView({ item, size }: ItemIconProps) { ); } -function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps) { +function ToolCardImpl({ + item, + selected, + onToggle, + onConfigure, + isFavorited = false, + onToggleFavorite, +}: ToolCardProps) { const localize = useLocalize(); const { name, description } = useDisplayStrings(item); const { user } = useAuthContext(); const isNative = item.kind === 'builtin'; const kindLabel = localize(KIND_LABEL_KEYS[item.kind]); + const canFavorite = onToggleFavorite !== undefined && item.kind !== 'action'; const canConfigure = hasConfigurableSettings(item) && onConfigure !== undefined; const skill = item.kind === 'skill' ? item.skill : undefined; const isPublicSkill = skill?.isPublic === true; @@ -180,25 +190,50 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps) ) : null} - {(canConfigure || showInfoOnly) && ( - )} - > -