diff --git a/api/server/controllers/SkillStatesController.js b/api/server/controllers/SkillStatesController.js new file mode 100644 index 0000000000..35679d2ac6 --- /dev/null +++ b/api/server/controllers/SkillStatesController.js @@ -0,0 +1,93 @@ +const mongoose = require('mongoose'); +const { logger } = require('@librechat/data-schemas'); +const { + MAX_SKILL_STATES, + toSkillStatesRecord, + validateSkillStatesPayload, + pruneOrphanSkillStates, +} = require('@librechat/api'); +const { ResourceType, PermissionBits } = require('librechat-data-provider'); +const { findAccessibleResources } = require('~/server/services/PermissionService'); +const { updateUser, getUserById } = require('~/models'); + +/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */ +function buildPruneDeps(user) { + return { + findExistingSkillIds: async (validIds) => { + const Skill = mongoose.models.Skill; + if (!Skill) { + return validIds; + } + const existing = await Skill.find({ _id: { $in: validIds } }) + .select('_id') + .lean(); + return existing.map((doc) => doc._id.toString()); + }, + findAccessibleSkillIds: () => + findAccessibleResources({ + userId: user.id, + role: user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }), + }; +} + +const getSkillStatesController = async (req, res) => { + try { + const userId = req.user.id; + const user = await getUserById(userId, 'skillStates'); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const states = toSkillStatesRecord(user.skillStates); + const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user)); + return res.status(200).json(pruned); + } catch (error) { + logger.error('[SkillStatesController] Error fetching skill states:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +const updateSkillStatesController = async (req, res) => { + try { + const { skillStates } = req.body; + + const validationError = validateSkillStatesPayload(skillStates); + if (validationError) { + const { message, code, limit } = validationError; + const payload = { message }; + if (code) payload.code = code; + if (limit != null) payload.limit = limit; + return res.status(400).json(payload); + } + + const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user)); + + if (Object.keys(pruned).length > MAX_SKILL_STATES) { + return res.status(400).json({ + code: 'MAX_SKILL_STATES_EXCEEDED', + message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`, + limit: MAX_SKILL_STATES, + }); + } + + const user = await updateUser(req.user.id, { skillStates: pruned }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + return res.status(200).json(toSkillStatesRecord(user.skillStates)); + } catch (error) { + logger.error('[SkillStatesController] Error updating skill states:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +module.exports = { + getSkillStatesController, + updateSkillStatesController, +}; diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index fd876f03ea..fbabe1bda3 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -41,6 +41,7 @@ jest.mock('@librechat/api', () => ({ createChunk: jest.fn().mockReturnValue({}), buildToolSet: jest.fn().mockReturnValue(new Set()), scopeSkillIds: jest.fn().mockImplementation((ids) => ids), + loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }), sendFinalChunk: jest.fn(), createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }), validateRequest: jest diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 720bd1e4f3..0824d38d1a 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -42,6 +42,7 @@ jest.mock('@librechat/api', () => ({ }), buildToolSet: jest.fn().mockReturnValue(new Set()), scopeSkillIds: jest.fn().mockImplementation((ids) => ids), + loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }), createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }), initializeAgent: jest.fn().mockResolvedValue({ id: 'agent-123', diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 13b1fa0d72..4a5dd7b423 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -14,6 +14,7 @@ const { createChunk, buildToolSet, scopeSkillIds, + loadSkillStates, sendFinalChunk, createSafeUser, validateRequest, @@ -256,6 +257,13 @@ const OpenAIChatCompletionController = async (req, res) => { }) : []; + const { skillStates, defaultActiveOnShare } = await loadSkillStates({ + userId: req.user.id, + appConfig, + getUserById: db.getUserById, + accessibleSkillIds, + }); + const primaryConfig = await initializeAgent( { req, @@ -273,6 +281,8 @@ const OpenAIChatCompletionController = async (req, res) => { ephemeralSkillsToggle ? undefined : agent.skills, ), codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + skillStates, + defaultActiveOnShare, }, dbMethods, ); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 772b564fcc..585a41c4a4 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -13,6 +13,7 @@ const { createRun, buildToolSet, scopeSkillIds, + loadSkillStates, createSafeUser, initializeAgent, getBalanceConfig, @@ -385,6 +386,13 @@ const createResponse = async (req, res) => { }) : []; + const { skillStates, defaultActiveOnShare } = await loadSkillStates({ + userId: req.user.id, + appConfig, + getUserById: db.getUserById, + accessibleSkillIds, + }); + const primaryConfig = await initializeAgent( { req, @@ -402,6 +410,8 @@ const createResponse = async (req, res) => { ephemeralSkillsToggle ? undefined : agent.skills, ), codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + skillStates, + defaultActiveOnShare, }, dbMethods, ); diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js index 22162fed4e..c6b7c84b2c 100644 --- a/api/server/routes/settings.js +++ b/api/server/routes/settings.js @@ -3,11 +3,17 @@ const { updateFavoritesController, getFavoritesController, } = require('~/server/controllers/FavoritesController'); +const { + getSkillStatesController, + updateSkillStatesController, +} = require('~/server/controllers/SkillStatesController'); const { requireJwtAuth } = require('~/server/middleware'); const router = express.Router(); router.get('/favorites', requireJwtAuth, getFavoritesController); router.post('/favorites', requireJwtAuth, updateFavoritesController); +router.get('/skills/active', requireJwtAuth, getSkillStatesController); +router.post('/skills/active', requireJwtAuth, updateSkillStatesController); module.exports = router; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 08a4eb03ac..32f897b23f 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -2,6 +2,7 @@ const { logger } = require('@librechat/data-schemas'); const { EnvVar, createContentAggregator } = require('@librechat/agents'); const { scopeSkillIds, + loadSkillStates, initializeAgent, primeInvokedSkills, validateAgentModel, @@ -124,6 +125,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { }) : []; + const { skillStates, defaultActiveOnShare } = await loadSkillStates({ + userId: req.user.id, + appConfig, + getUserById: db.getUserById, + accessibleSkillIds, + }); + // Resolve code API key once for the entire run (shared by primeInvokedSkills // and enrichWithSkillConfigurable) to avoid redundant auth lookups. let codeApiKey; @@ -243,6 +251,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { ephemeralSkillsToggle ? undefined : primaryAgent.skills, ), codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + skillStates, + defaultActiveOnShare, }, { getFiles: db.getFiles, @@ -290,6 +300,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { parentMessageId, computeAccessibleSkillIds: (agent) => scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills), + skillStates, + defaultActiveOnShare, }, { getAgent: db.getAgent, diff --git a/client/src/components/Skills/buttons/SkillToggle.tsx b/client/src/components/Skills/buttons/SkillToggle.tsx new file mode 100644 index 0000000000..ff948b1fac --- /dev/null +++ b/client/src/components/Skills/buttons/SkillToggle.tsx @@ -0,0 +1,40 @@ +import { memo } from 'react'; +import { cn } from '~/utils'; + +interface SkillToggleProps { + enabled: boolean; + onChange: () => void; + ariaLabel: string; +} + +function SkillToggle({ enabled, onChange, ariaLabel }: SkillToggleProps) { + return ( + + ); +} + +export default memo(SkillToggle); diff --git a/client/src/components/Skills/buttons/index.ts b/client/src/components/Skills/buttons/index.ts index 2ce3a1af96..93ad2e048e 100644 --- a/client/src/components/Skills/buttons/index.ts +++ b/client/src/components/Skills/buttons/index.ts @@ -1,3 +1,4 @@ export { default as AdminSettings } from './AdminSettings'; +export { default as SkillToggle } from './SkillToggle'; export { default as ShareSkill } from './ShareSkill'; export { default as CreateSkillMenu } from './CreateSkillMenu'; diff --git a/client/src/components/Skills/display/SkillDetail.tsx b/client/src/components/Skills/display/SkillDetail.tsx index 1dab6c4b0a..bbd9dacf52 100644 --- a/client/src/components/Skills/display/SkillDetail.tsx +++ b/client/src/components/Skills/display/SkillDetail.tsx @@ -3,11 +3,11 @@ import { format } from 'date-fns'; import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react'; import { TooltipAnchor } from '@librechat/client'; import type { TSkill } from 'librechat-data-provider'; -import { useLocalize, useAuthContext, useSkillPermissions } from '~/hooks'; +import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks'; +import { ShareSkill, SkillToggle } from '../buttons'; import SkillMarkdownRenderer from './SkillMarkdownRenderer'; import { parseFrontmatter } from '../utils'; import DeleteSkill from '../dialogs/DeleteSkill'; -import { ShareSkill } from '../buttons'; import { cn } from '~/utils'; interface SkillDetailProps { @@ -68,7 +68,9 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp const localize = useLocalize(); const { user } = useAuthContext(); const permissions = useSkillPermissions(skill); + const { isActive, toggle } = useSkillActiveState(); const [viewMode, setViewMode] = useState<'rendered' | 'source'>('rendered'); + const skillEnabled = isActive(skill); const isPublic = skill.isPublic === true; const isShared = skill.author !== user?.id && Boolean(skill.authorName); @@ -130,6 +132,11 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp {/* Actions */}
+ toggle(skill)} + ariaLabel={localize('com_ui_skill_toggle_active')} + /> {permissions.canEdit && onEdit && (