From aa7539909b8af9f6b2a634613bb91eb93ec4a810 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 1 Sep 2026 18:39:05 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=B6=20feat:=20Enable=20Standalone=20Sk?= =?UTF-8?q?ill=20Authoring=20(#15480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enable standalone skill authoring * test: align skill e2e flow with enable toggle * fix: preserve legacy skill state on version restore --- api/server/controllers/agents/client.js | 2 + api/server/controllers/agents/v1.js | 34 ++++- api/server/controllers/agents/v1.spec.js | 29 ++++ .../Endpoints/agents/initialize.spec.js | 35 +++++ .../services/Endpoints/agents/skillDeps.js | 16 ++- client/src/common/agents-types.ts | 3 + .../components/Chat/Input/SkillsCommand.tsx | 23 +++- .../Input/__tests__/SkillsCommand.spec.tsx | 59 ++++++-- .../src/components/Chat/Input/skillsState.ts | 4 + .../SidePanel/Agents/AgentPanel.tsx | 4 + .../SidePanel/Agents/Tools/SkillsDialog.tsx | 19 ++- .../SidePanel/Agents/Tools/ToolsSection.tsx | 127 ++++++++++++++++-- .../Tools/__tests__/ToolsSection.spec.tsx | 119 +++++++++++++--- .../Tools/items/__tests__/mutations.spec.ts | 43 +++--- .../SidePanel/Agents/Tools/items/mutations.ts | 34 ++--- .../__tests__/AgentPanel.helpers.spec.ts | 12 ++ client/src/hooks/Config/useClearStates.ts | 7 +- client/src/hooks/Input/useHandleKeyUp.spec.ts | 15 ++- client/src/hooks/Input/useHandleKeyUp.ts | 4 +- client/src/locales/en/translation.json | 2 + client/src/store/families.ts | 6 - e2e/specs/mock/agent-skills.spec.ts | 11 ++ .../api/src/agents/__tests__/skills.test.ts | 46 ++++++- packages/api/src/agents/lazySubagents.spec.ts | 17 +++ packages/api/src/agents/lazySubagents.ts | 6 + packages/api/src/agents/skills.ts | 21 ++- packages/api/src/agents/validation.ts | 9 ++ packages/data-provider/src/schemas.ts | 26 ++++ .../data-provider/src/types/assistants.ts | 10 +- .../data-schemas/src/methods/agent.spec.ts | 31 +++++ packages/data-schemas/src/methods/agent.ts | 19 ++- packages/data-schemas/src/schema/agent.ts | 10 ++ packages/data-schemas/src/types/agent.ts | 3 + 33 files changed, 683 insertions(+), 123 deletions(-) create mode 100644 client/src/components/Chat/Input/skillsState.ts diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 3f06de59ee..b862dd1dc3 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -429,6 +429,8 @@ class AgentClient extends BaseClient { subagents: agent.subagents, memory_scope: agent.memory_scope, skills_enabled: agent.skills_enabled, + skill_authoring_enabled: agent.skill_authoring_enabled, + skills_scope: agent.skills_scope, skills: agent.skills, backgroundToolNames: agent.backgroundToolNames, intentToolNames: agent.intentToolNames, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index f412b152ff..d7b155fec6 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -41,6 +41,7 @@ const { const { Time, Tools, + SkillsScope, CacheKeys, Constants, FileSources, @@ -241,18 +242,38 @@ const blockFilteredAgentContent = async (req, res, agentData) => { const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => { const skillScopeEnabled = agent.skills_enabled === true; + const configuredScope = agent.skills_scope; delete agent.skills_enabled; + delete agent.skills_scope; if (!skillScopeEnabled) { delete agent.skills; return agent; } - const configuredSkills = Array.isArray(agent.skills) ? agent.skills : []; - if (configuredSkills.length === 0) { - // Empty allowlist means the viewer's full accessible catalog. + if (configuredScope === SkillsScope.none) { delete agent.skills; agent.skills_enabled = true; + agent.skills_scope = SkillsScope.none; + return agent; + } + + if (configuredScope === SkillsScope.all) { + delete agent.skills; + agent.skills_enabled = true; + agent.skills_scope = SkillsScope.all; + return agent; + } + + const configuredSkills = Array.isArray(agent.skills) ? agent.skills : []; + if (configuredSkills.length === 0) { + // Legacy empty allowlists mean the viewer's full accessible catalog; + // explicit selected scope remains an intentionally empty catalog. + delete agent.skills; + agent.skills_enabled = true; + if (configuredScope === SkillsScope.selected) { + agent.skills_scope = SkillsScope.selected; + } return agent; } @@ -262,11 +283,18 @@ const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => { if (visibleSkills.length === 0) { delete agent.skills; + if (configuredScope === SkillsScope.selected) { + agent.skills_enabled = true; + agent.skills_scope = SkillsScope.selected; + } return agent; } agent.skills = visibleSkills; agent.skills_enabled = true; + if (configuredScope === SkillsScope.selected) { + agent.skills_scope = SkillsScope.selected; + } return agent; }; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index ef1345586f..daef3a4981 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -4,6 +4,7 @@ const { v4: uuidv4 } = require('uuid'); const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas'); const { Tools, + SkillsScope, FileSources, PermissionBits, PrincipalModel, @@ -3210,6 +3211,34 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data[0].skills_enabled).toBe(true); }); + test('should preserve an enabled empty catalog for VIEW list callers', async () => { + await Agent.findByIdAndUpdate(agentA1._id, { + skills_enabled: false, + skill_authoring_enabled: true, + skills_scope: SkillsScope.none, + skills: [], + }); + + mockReq.user.id = userB.toString(); + mockReq.query.requiredPermission = String(PermissionBits.VIEW); + findAccessibleResources.mockImplementation(({ resourceType }) => { + if (resourceType === ResourceType.AGENT) { + return Promise.resolve([agentA1._id]); + } + return Promise.resolve([]); + }); + findPubliclyAccessibleResources.mockResolvedValue([]); + + await getListAgentsHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.data).toHaveLength(1); + expect(response.data[0].skills).toBeUndefined(); + expect(response.data[0].skills_enabled).toBeUndefined(); + expect(response.data[0].skill_authoring_enabled).toBe(true); + expect(response.data[0].skills_scope).toBeUndefined(); + }); + test('should return raw skill configuration for EDIT list callers', async () => { const visibleSkillId = new mongoose.Types.ObjectId(); const hiddenSkillId = new mongoose.Types.ObjectId(); diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index d23bde9089..cb80c3807b 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -551,6 +551,41 @@ describe('initializeClient — processAgent ACL gate', () => { expect(initializeParams.skillAuthoringAvailable).toBe(true); }); + it('enables standalone authoring without exposing the persisted agent skill catalog', async () => { + const endpointOption = makeEndpointOption(); + endpointOption.agent = Promise.resolve({ + id: PRIMARY_ID, + name: 'Primary', + provider: 'openai', + model: 'gpt-4', + tools: [], + skills: [], + skills_enabled: false, + skill_authoring_enabled: true, + }); + mockInitializeAgent.mockResolvedValue(makePrimaryConfig([])); + const req = makeReq(); + req.config.endpoints.agents = { capabilities: ['skills'] }; + const canCreateSkillSpy = jest + .spyOn(getSkillToolDeps(), 'canCreateSkill') + .mockResolvedValue(true); + + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption, + }); + } finally { + canCreateSkillSpy.mockRestore(); + } + + const initializeParams = mockInitializeAgent.mock.calls[0][0]; + expect(initializeParams.accessibleSkillIds).toEqual([]); + expect(initializeParams.skillAuthoringAvailable).toBe(true); + }); + it('loads model validation and skill permissions without serial waits', async () => { const models = deferred(); const createPermission = deferred(); diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 485e37f198..b58983da9a 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -158,7 +158,11 @@ function canEditSkill({ req, skillId }) { }); } -function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralSkillsToggle }) { +function isAgentSkillAuthoringEnabledForRun({ + agent, + skillsCapabilityEnabled, + ephemeralSkillsToggle, +}) { if (!skillsCapabilityEnabled) { return false; } @@ -171,7 +175,7 @@ function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralS } return ephemeralSkillsToggle === true; } - return agent.skills_enabled === true; + return agent.skills_enabled === true || agent.skill_authoring_enabled === true; } function canAuthorSkillFiles({ @@ -182,7 +186,11 @@ function canAuthorSkillFiles({ ephemeralSkillsToggle, }) { return ( - isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralSkillsToggle }) && + isAgentSkillAuthoringEnabledForRun({ + agent, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }) && (scopedEditableSkillIds.length > 0 || skillCreateAllowed === true) ); } @@ -397,7 +405,7 @@ function getSkillToolDeps() { module.exports = { getSkillToolDeps, canAuthorSkillFiles, - isAgentSkillsEnabledForRun, + isAgentSkillAuthoringEnabledForRun, getSkillDbMethods, withDeploymentSkillIds, getSkillStrategyFunctions, diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 1500d9e503..9808f4c973 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -6,6 +6,7 @@ import type { SupportContact, AgentProvider, MemoryScope, + SkillsScope, StatefulCodeEnvironment, GraphEdge, Agent, @@ -43,6 +44,8 @@ export type AgentForm = { tool_options?: AgentToolOptions; skills?: string[]; skills_enabled?: boolean; + skill_authoring_enabled?: boolean; + skills_scope?: SkillsScope; /** Memory partition: 'agent' isolates memories per (user, agent); default shared pool */ memory_scope?: MemoryScope; /** Sharing scope for stateful Code API workspaces. */ diff --git a/client/src/components/Chat/Input/SkillsCommand.tsx b/client/src/components/Chat/Input/SkillsCommand.tsx index b04c6b2e7e..27d5ad91e9 100644 --- a/client/src/components/Chat/Input/SkillsCommand.tsx +++ b/client/src/components/Chat/Input/SkillsCommand.tsx @@ -1,13 +1,16 @@ import { memo, useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { ScrollText } from 'lucide-react'; +import { useSetRecoilState } from 'recoil'; +import { useAtomValue, useSetAtom } from 'jotai'; import { AutoSizer, List } from 'react-virtualized'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Input, Spinner, useCombobox } from '@librechat/client'; +import { SkillsScope, resolveAgentSkillsScope } from 'librechat-data-provider'; import type { TSkillSummary } from 'librechat-data-provider'; import type { MentionOption } from '~/common'; import useInitPopoverInput from '~/hooks/Input/useInitPopoverInput'; import { useLocalize, useSkillActiveState } from '~/hooks'; import { useSkillsInfiniteQuery } from '~/data-provider'; +import { showSkillsPopoverFamily } from './skillsState'; import { useAgentsMapContext } from '~/Providers'; import { ephemeralAgentByConvoId } from '~/store'; import { isEphemeralAgent } from '~/common'; @@ -86,7 +89,7 @@ function SkillsCommandContent({ agentId?: string | null; }) { const localize = useLocalize(); - const setShowSkillsPopover = useSetRecoilState(store.showSkillsPopoverFamily(index)); + const setShowSkillsPopover = useSetAtom(showSkillsPopoverFamily(index)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId)); const setPendingManualSkills = useSetRecoilState( store.pendingManualSkillsByConvoId(conversationId), @@ -99,9 +102,8 @@ function SkillsCommandContent({ `resolveAgentScopedSkillIds`: ephemeral agents always see the full catalog (picking any skill flips `ephemeralAgent.skills = true` and activates for the turn); persisted agents gate on the builder's - `skills_enabled` master toggle — off or unset means opt-out, on with - an empty allowlist means full catalog, on with a non-empty allowlist - means narrow to those ids. Persisted agents fail closed during the + `skills_enabled` master toggle and explicit catalog scope. Legacy agents + without a scope retain empty allowlist = full catalog. Persisted agents fail closed during the `agentsMap` hydration window so the user cannot pick a skill the backend will then refuse, and again when the map is authoritative but the agent isn't in it (deleted, or VIEW revoked mid-session). @@ -121,7 +123,14 @@ function SkillsCommandContent({ if (agent.skills_enabled !== true) { return []; } - return Array.isArray(agent.skills) && agent.skills.length > 0 ? agent.skills : undefined; + const scope = resolveAgentSkillsScope(agent.skills, agent.skills_enabled, agent.skills_scope); + if (scope === SkillsScope.none) { + return []; + } + if (scope === SkillsScope.all) { + return undefined; + } + return agent.skills ?? []; }, [agentId, agentsMap]); const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = @@ -375,7 +384,7 @@ const SkillsCommand = memo(function SkillsCommand({ conversationId: string; agentId?: string | null; }) { - const show = useRecoilValue(store.showSkillsPopoverFamily(index)); + const show = useAtomValue(showSkillsPopoverFamily(index)); if (!show) { return null; } diff --git a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx index e032206950..6cc2a17fa6 100644 --- a/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx +++ b/client/src/components/Chat/Input/__tests__/SkillsCommand.spec.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { SkillsScope } from 'librechat-data-provider'; import { render, screen } from '@testing-library/react'; import type { TSkillSummary } from 'librechat-data-provider'; @@ -23,21 +24,27 @@ const mockSetEphemeralAgent = jest.fn(); const mockSetPendingManualSkills = jest.fn(); const mockShowSkillsPopover = { current: true }; +jest.mock('jotai', () => ({ + ...jest.requireActual('jotai'), + useAtomValue: jest.fn((atom: unknown) => + atom === 'show-skills-popover' ? mockShowSkillsPopover.current : undefined, + ), + useSetAtom: jest.fn((atom: unknown) => + atom === 'show-skills-popover' ? mockSetShowSkillsPopover : jest.fn(), + ), +})); + +jest.mock('../skillsState', () => ({ + showSkillsPopoverFamily: () => 'show-skills-popover', +})); + jest.mock('recoil', () => { const actual = jest.requireActual('recoil'); return { ...actual, - useRecoilValue: jest.fn((atom: unknown) => { - if (atom === 'show-skills-popover') { - return mockShowSkillsPopover.current; - } - return undefined; - }), + useRecoilValue: jest.fn(() => undefined), useRecoilState: jest.fn(() => [null, jest.fn()]), useSetRecoilState: jest.fn((atom: unknown) => { - if (atom === 'show-skills-popover') { - return mockSetShowSkillsPopover; - } if (atom === 'ephemeral-agent') { return mockSetEphemeralAgent; } @@ -52,7 +59,6 @@ jest.mock('recoil', () => { jest.mock('~/store', () => ({ __esModule: true, default: { - showSkillsPopoverFamily: () => 'show-skills-popover', pendingManualSkillsByConvoId: () => 'pending-manual-skills', }, ephemeralAgentByConvoId: () => 'ephemeral-agent', @@ -379,6 +385,39 @@ describe('SkillsCommand', () => { expect(screen.getByRole('button', { name: /Style Guide/i })).toBeInTheDocument(); }); + it('shows no catalog entries for an enabled authoring-only agent', () => { + mockUseSkillsInfiniteQuery.mockReturnValue({ + data: twoSkillsResponse, + isLoading: false, + isError: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + }); + mockUseAgentsMapContext.mockReturnValue({ + agent_1: { + id: 'agent_1', + skills: [], + skills_enabled: false, + skill_authoring_enabled: true, + skills_scope: SkillsScope.none, + }, + }); + + const textAreaRef = makeTextarea('$'); + render( + , + ); + + expect(screen.queryByRole('button', { name: /Brand Guidelines/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Style Guide/i })).toBeNull(); + }); + it('treats an ephemeral agent id as unscoped and shows the full ACL catalog', async () => { mockUseSkillsInfiniteQuery.mockReturnValue({ data: twoSkillsResponse, diff --git a/client/src/components/Chat/Input/skillsState.ts b/client/src/components/Chat/Input/skillsState.ts new file mode 100644 index 0000000000..a7982a498f --- /dev/null +++ b/client/src/components/Chat/Input/skillsState.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +export const showSkillsPopoverFamily = atomFamily((_index: string | number | null) => atom(false)); diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 63a9b593fa..3ea8cfc854 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -91,6 +91,8 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n tool_options, skills, skills_enabled, + skill_authoring_enabled, + skills_scope, memory_scope, avatar_action: avatarActionState, } = data; @@ -132,6 +134,8 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n tool_options: normalizedToolOptions, skills, skills_enabled, + skill_authoring_enabled, + skills_scope, /** A hidden stale 'agent' scope must not survive disabling memory — * runtime partitioning keys off memory_scope alone. */ memory_scope: data.memory === true ? memory_scope : MemoryScope.user, diff --git a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx index e57b503247..dbcdc906bc 100644 --- a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx @@ -19,7 +19,7 @@ import type { AgentItem } from './items/types'; import type { AgentForm } from '~/common'; import { useLocalize, useHasAccess, useAuthContext, useToolFavorites } from '~/hooks'; import { CreateSkillDialog } from '~/components/Skills/dialogs'; -import { skillsEnabledTransition } from './items/mutations'; +import { skillsSelectionTransition } from './items/mutations'; import { useSkillsInfiniteQuery } from '~/data-provider'; import MarketplaceCatalog from './MarketplaceCatalog'; import { CategoryIcon } from '~/components/Prompts'; @@ -143,9 +143,20 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial const applySkillsSelection = useCallback( (next: string[]) => { setValue('skills', next, { shouldDirty: true }); - const flag = skillsEnabledTransition(next, getValues('skills_enabled')); - if (flag !== undefined) { - setValue('skills_enabled', flag, { shouldDirty: true }); + const transition = skillsSelectionTransition( + next, + getValues('skills_enabled'), + getValues('skill_authoring_enabled'), + getValues('skills_scope'), + ); + if (transition.enabled !== undefined) { + setValue('skills_enabled', transition.enabled, { shouldDirty: true }); + } + if (transition.authoringEnabled !== undefined) { + setValue('skill_authoring_enabled', transition.authoringEnabled, { shouldDirty: true }); + } + if (transition.scope !== undefined) { + setValue('skills_scope', transition.scope, { shouldDirty: true }); } }, [getValues, setValue], diff --git a/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx b/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx index 4f06069d00..4e7634a5d8 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx @@ -1,12 +1,6 @@ import { useState, useMemo, useCallback, useRef } from 'react'; import { Plus } from 'lucide-react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { - PermissionTypes, - Permissions, - AgentCapabilities, - removeCodeExecutionCaller, -} from 'librechat-data-provider'; import { Label, Switch, @@ -17,6 +11,14 @@ import { OGDialogTemplate, useToastContext, } from '@librechat/client'; +import { + PermissionTypes, + Permissions, + SkillsScope, + AgentCapabilities, + resolveAgentSkillsScope, + removeCodeExecutionCaller, +} from 'librechat-data-provider'; import type { TPlugin } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import type { CapabilityFileCounts } from './items/capabilities'; @@ -28,7 +30,7 @@ import { useResolvedSkills, useUninstallToolCredentials, } from './hooks'; -import { computeToggleAction, skillsEnabledTransition } from './items/mutations'; +import { computeToggleAction, skillsSelectionTransition } from './items/mutations'; import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider'; import { requiresFileManagerRemoval } from './items/capabilities'; import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP'; @@ -90,7 +92,16 @@ export default function ToolsSection({ agentId }: Props) { const skillsValue = useWatch({ control, name: 'skills' }); const skillsEnabledValue = useWatch({ control, name: 'skills_enabled' }); - const useAllSkills = skillsEnabledValue === true && (skillsValue ?? []).length === 0; + const skillAuthoringEnabledValue = useWatch({ control, name: 'skill_authoring_enabled' }); + const skillsScopeValue = useWatch({ control, name: 'skills_scope' }); + const explicitSkillsEnabled = skillsEnabledValue === true; + const skillsCapabilityEnabled = explicitSkillsEnabled || skillAuthoringEnabledValue === true; + const effectiveSkillsScope = resolveAgentSkillsScope( + skillsValue, + skillsEnabledValue, + skillsScopeValue, + ); + const useAllSkills = explicitSkillsEnabled && effectiveSkillsScope === SkillsScope.all; /** Selection stashed when "use all skills" turns on, so turning it back off * restores the previous picks instead of destroying them. Cleared when the * agent changes — the section isn't remounted on switch (only the form @@ -103,17 +114,65 @@ export default function ToolsSection({ agentId }: Props) { stashedSkillsRef.current = []; } + const handleSkillsEnabledChange = useCallback( + (checked: boolean) => { + if (!checked) { + setValue('skills_enabled', false, { shouldDirty: true }); + setValue('skill_authoring_enabled', false, { shouldDirty: true }); + return; + } + const current = (getValues('skills') ?? []) as string[]; + if (current.length === 0 && getValues('skills_scope') === SkillsScope.all) { + setValue('skills_enabled', true, { shouldDirty: true }); + setValue('skill_authoring_enabled', false, { shouldDirty: true }); + return; + } + const transition = skillsSelectionTransition( + current, + getValues('skills_enabled'), + getValues('skill_authoring_enabled'), + getValues('skills_scope'), + ); + if (transition.enabled !== undefined) { + setValue('skills_enabled', transition.enabled, { shouldDirty: true }); + } + if (transition.authoringEnabled !== undefined) { + setValue('skill_authoring_enabled', transition.authoringEnabled, { shouldDirty: true }); + } + if (transition.scope !== undefined) { + setValue('skills_scope', transition.scope, { shouldDirty: true }); + } + }, + [getValues, setValue], + ); + const handleUseAllSkillsChange = useCallback( (checked: boolean) => { if (checked) { stashedSkillsRef.current = (getValues('skills') ?? []) as string[]; setValue('skills', [], { shouldDirty: true }); setValue('skills_enabled', true, { shouldDirty: true }); + setValue('skill_authoring_enabled', false, { shouldDirty: true }); + setValue('skills_scope', SkillsScope.all, { shouldDirty: true }); return; } const restored = stashedSkillsRef.current; setValue('skills', restored, { shouldDirty: true }); - setValue('skills_enabled', restored.length > 0, { shouldDirty: true }); + const transition = skillsSelectionTransition( + restored, + getValues('skills_enabled'), + getValues('skill_authoring_enabled'), + getValues('skills_scope'), + ); + if (transition.enabled !== undefined) { + setValue('skills_enabled', transition.enabled, { shouldDirty: true }); + } + if (transition.authoringEnabled !== undefined) { + setValue('skill_authoring_enabled', transition.authoringEnabled, { shouldDirty: true }); + } + if (transition.scope !== undefined) { + setValue('skills_scope', transition.scope, { shouldDirty: true }); + } }, [getValues, setValue], ); @@ -168,9 +227,22 @@ export default function ToolsSection({ agentId }: Props) { const current = (getValues('skills') ?? []) as string[]; const next = current.filter((s) => s !== patch.id); setValue('skills', next, { shouldDirty: true }); - const flag = skillsEnabledTransition(next, getValues('skills_enabled')); - if (flag !== undefined) { - setValue('skills_enabled', flag, { shouldDirty: true }); + const transition = skillsSelectionTransition( + next, + getValues('skills_enabled'), + getValues('skill_authoring_enabled'), + getValues('skills_scope'), + ); + if (transition.enabled !== undefined) { + setValue('skills_enabled', transition.enabled, { shouldDirty: true }); + } + if (transition.authoringEnabled !== undefined) { + setValue('skill_authoring_enabled', transition.authoringEnabled, { + shouldDirty: true, + }); + } + if (transition.scope !== undefined) { + setValue('skills_scope', transition.scope, { shouldDirty: true }); } break; } @@ -288,9 +360,35 @@ export default function ToolsSection({ agentId }: Props) { onInfo={setDialogItem} onRemove={handleQuickRemove} badgeText={useAllSkills ? localize('com_ui_all_proper') : undefined} - showAdd={!useAllSkills} - showBody={!useAllSkills} + showAdd={skillsCapabilityEnabled && !useAllSkills} + showBody={skillsCapabilityEnabled && !useAllSkills} > +
+
+ + {localize('com_ui_skills_enable')} + + + + + +

+ {localize('com_ui_skills_enable_hint')} +

+
+
+
+
+ +
diff --git a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx index 38672bb5e4..75f8bff4db 100644 --- a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom/extend-expect'; +import { SkillsScope } from 'librechat-data-provider'; import { render, screen, fireEvent } from '@testing-library/react'; import type { AgentItem } from '../items/types'; import ToolsSection from '../ToolsSection'; @@ -89,10 +90,14 @@ jest.mock('@librechat/client', () => ({ Switch: ({ id, checked, + disabled, + 'aria-labelledby': ariaLabelledBy, onCheckedChange, }: { id?: string; checked?: boolean; + disabled?: boolean; + 'aria-labelledby'?: string; onCheckedChange?: (value: boolean) => void; }) => (