diff --git a/api/server/routes/user.js b/api/server/routes/user.js index dbe42f1164..90f247cd36 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -1,4 +1,5 @@ const express = require('express'); +const { createUserPreferencesHandler } = require('@librechat/api'); const { updateUserPluginsController, resendVerificationController, @@ -17,11 +18,17 @@ const { } = require('~/server/middleware'); const settings = require('./settings'); +const { updateUserStatefulCodeEnvironment } = require('~/models'); const router = express.Router(); +const updateUserPreferences = createUserPreferencesHandler({ + updateStatefulCodeEnvironment: updateUserStatefulCodeEnvironment, +}); + router.use('/settings', settings); router.get('/', requireJwtAuth, getUserController); +router.patch('/preferences', requireJwtAuth, updateUserPreferences); router.get('/terms', requireJwtAuth, getTermsStatusController); router.post('/terms/accept', requireJwtAuth, acceptTermsController); router.post('/plugins', requireJwtAuth, updateUserPluginsController); diff --git a/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx b/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx new file mode 100644 index 0000000000..2d2f2c5857 --- /dev/null +++ b/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { + Select, + SelectItem, + SelectValue, + SelectContent, + SelectTrigger, + useToastContext, +} from '@librechat/client'; + +import type { StatefulCodeEnvironment } from 'librechat-data-provider'; +import { useGetUserQuery, useUpdateUserPreferencesMutation } from '~/data-provider'; +import { useLocalize } from '~/hooks'; + +const FALLBACK_ENVIRONMENT: StatefulCodeEnvironment = 'user'; + +export default function StatefulWorkspaceDefault() { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { data: user } = useGetUserQuery(); + const savedEnvironment = user?.personalization?.statefulCodeEnvironment ?? FALLBACK_ENVIRONMENT; + const [environment, setEnvironment] = useState(savedEnvironment); + + useEffect(() => { + setEnvironment(savedEnvironment); + }, [savedEnvironment]); + + const mutation = useUpdateUserPreferencesMutation({ + onSuccess: () => + showToast({ message: localize('com_ui_preferences_updated'), status: 'success' }), + onError: () => { + setEnvironment(savedEnvironment); + showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' }); + }, + }); + + const handleChange = (value: string) => { + const statefulCodeEnvironment = value as StatefulCodeEnvironment; + setEnvironment(statefulCodeEnvironment); + mutation.mutate({ statefulCodeEnvironment }); + }; + + return ( +
+
+ +

+ {localize('com_ui_default_stateful_workspace_description')} +

+
+ +
+ ); +} diff --git a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx index 02184e9d70..8272fe91d7 100644 --- a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx +++ b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx @@ -9,6 +9,7 @@ const ctx: SettingsContextValue = { balanceEnabled: false, hasAnyPersonalizationFeature: false, hasMemoryOptOut: false, + hasStatefulCodeSessions: false, hasRemoteAgents: false, hasUserProvidedEndpoints: false, hasMultiConvo: false, diff --git a/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx b/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx new file mode 100644 index 0000000000..adfe650432 --- /dev/null +++ b/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx @@ -0,0 +1,74 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import StatefulWorkspaceDefault from '../StatefulWorkspaceDefault'; + +const mockMutate = jest.fn(); +const mockShowToast = jest.fn(); + +jest.mock('@librechat/client', () => ({ + Select: ({ + value, + onValueChange, + disabled, + children, + }: { + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + children: ReactNode; + }) => ( + + ), + SelectTrigger: ({ children }: { children: ReactNode }) => children, + SelectValue: () => null, + SelectContent: ({ children }: { children: ReactNode }) => children, + SelectItem: ({ value, children }: { value: string; children: ReactNode }) => ( + + ), + useToastContext: () => ({ showToast: mockShowToast }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/data-provider', () => ({ + useGetUserQuery: () => ({ + data: { personalization: { statefulCodeEnvironment: 'agent-user' } }, + }), + useUpdateUserPreferencesMutation: () => ({ + mutate: mockMutate, + isLoading: false, + }), +})); + +describe('StatefulWorkspaceDefault', () => { + beforeEach(() => { + mockMutate.mockClear(); + }); + + it('shows the saved user preference', () => { + render(); + + expect(screen.getByRole('combobox', { name: 'workspace-default' })).toHaveValue('agent-user'); + }); + + it('persists a new default for future agents', async () => { + render(); + + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'workspace-default' }), + 'conversation', + ); + + expect(mockMutate).toHaveBeenCalledWith({ statefulCodeEnvironment: 'conversation' }); + }); +}); diff --git a/client/src/components/Nav/Settings/__tests__/registry.spec.ts b/client/src/components/Nav/Settings/__tests__/registry.spec.ts index bf254ee51a..d277ebfe44 100644 --- a/client/src/components/Nav/Settings/__tests__/registry.spec.ts +++ b/client/src/components/Nav/Settings/__tests__/registry.spec.ts @@ -11,6 +11,7 @@ const settingsContext: SettingsContextValue = { balanceEnabled: false, hasAnyPersonalizationFeature: false, hasMemoryOptOut: false, + hasStatefulCodeSessions: false, hasRemoteAgents: false, hasUserProvidedEndpoints: false, hasMultiConvo: false, @@ -87,4 +88,16 @@ describe('settings registry', () => { ).toBe(true); }); }); + + describe('stateful workspace default visibility', () => { + const entry = registry.find((setting) => setting.id === 'defaultStatefulWorkspace'); + + it('shows the setting when stateful code sessions are available', () => { + expect(entry?.show?.({ ...settingsContext, hasStatefulCodeSessions: true })).toBe(true); + }); + + it('hides the setting when stateful code sessions are unavailable', () => { + expect(entry?.show?.({ ...settingsContext, hasStatefulCodeSessions: false })).toBe(false); + }); + }); }); diff --git a/client/src/components/Nav/Settings/context.tsx b/client/src/components/Nav/Settings/context.tsx index 28a941e057..bb725d3c79 100644 --- a/client/src/components/Nav/Settings/context.tsx +++ b/client/src/components/Nav/Settings/context.tsx @@ -1,16 +1,17 @@ import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { AgentCapabilities, PermissionTypes, Permissions } from 'librechat-data-provider'; import type { SettingsContextValue } from './types'; import useProviderKeys from '../SettingsTabs/ProviderKeys/useProviderKeys'; +import { useHasAccess, useAuthContext, useGetAgentsConfig } from '~/hooks'; import usePersonalizationAccess from '~/hooks/usePersonalizationAccess'; -import { useHasAccess, useAuthContext } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; import store from '~/store'; export function useSettingsContext(): SettingsContextValue { const { user } = useAuthContext(); const { data: startupConfig } = useGetStartupConfig(); + const { agentsConfig } = useGetAgentsConfig(); const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess(); const hasRemoteAgents = useHasAccess({ @@ -38,12 +39,15 @@ export function useSettingsContext(): SettingsContextValue { const hasPromptsBool = hasPrompts === true; const engineTTS = useRecoilValue(store.engineTTS); const hasUserProvidedEndpoints = useProviderKeys().length > 0; + const hasStatefulCodeSessions = + agentsConfig?.capabilities.includes(AgentCapabilities.stateful_code_sessions) ?? false; return useMemo( () => ({ balanceEnabled, hasAnyPersonalizationFeature, hasMemoryOptOut, + hasStatefulCodeSessions, hasRemoteAgents: hasRemoteAgentsBool, hasUserProvidedEndpoints, hasMultiConvo: hasMultiConvoBool, @@ -60,6 +64,7 @@ export function useSettingsContext(): SettingsContextValue { balanceEnabled, hasAnyPersonalizationFeature, hasMemoryOptOut, + hasStatefulCodeSessions, hasRemoteAgentsBool, hasUserProvidedEndpoints, hasMultiConvoBool, diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 7858a0805e..8a81a7f40d 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -29,6 +29,7 @@ import ChatTitleInTab from '../SettingsTabs/General/ChatTitleInTab'; import AdvancedPrompts from '../SettingsTabs/Chat/AdvancedPrompts'; import DuringRunAction from '../SettingsTabs/Chat/DuringRunAction'; import DeleteAccount from '../SettingsTabs/Account/DeleteAccount'; +import StatefulWorkspaceDefault from './StatefulWorkspaceDefault'; import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings'; import ChatDirection from '../SettingsTabs/Chat/ChatDirection'; import { DeleteCache } from '../SettingsTabs/Data/DeleteCache'; @@ -532,6 +533,16 @@ export const registry: SettingEntry[] = [ show: (ctx) => ctx.hasMemoryOptOut, Component: MemoryToggle, }, + // Data controls · Code execution + { + id: 'defaultStatefulWorkspace', + tab: DATA, + section: 'codeExecution', + labelKey: 'com_ui_default_stateful_workspace', + keywords: ['agent', 'code', 'environment', 'sandbox', 'stateful', 'workspace'], + show: (ctx) => ctx.hasStatefulCodeSessions, + Component: StatefulWorkspaceDefault, + }, // Data controls · Your data { id: 'importConversations', diff --git a/client/src/components/Nav/Settings/types.ts b/client/src/components/Nav/Settings/types.ts index 64c01c9331..faf1c6b124 100644 --- a/client/src/components/Nav/Settings/types.ts +++ b/client/src/components/Nav/Settings/types.ts @@ -27,6 +27,7 @@ export type SectionId = | 'stt' | 'tts' | 'memory' + | 'codeExecution' | 'data' | 'apiKeys' | 'langfuse' @@ -40,6 +41,7 @@ export interface SettingsContextValue { balanceEnabled: boolean; hasAnyPersonalizationFeature: boolean; hasMemoryOptOut: boolean; + hasStatefulCodeSessions: boolean; hasRemoteAgents: boolean; hasUserProvidedEndpoints: boolean; hasMultiConvo: boolean; @@ -141,6 +143,7 @@ export const TABS: TabMeta[] = [ icon: createElement(DataIcon), sections: [ { id: 'memory', labelKey: 'com_ui_settings_section_memory' }, + { id: 'codeExecution', labelKey: 'com_ui_settings_section_code_execution' }, { id: 'data', labelKey: 'com_ui_settings_section_data' }, { id: 'apiKeys', labelKey: 'com_ui_settings_section_api_keys' }, { id: 'danger', labelKey: 'com_ui_settings_section_danger_zone', danger: true }, diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx index 92b6ea7a29..4fce6a8633 100644 --- a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -15,11 +15,12 @@ import { } from '@librechat/client'; import type { StatefulCodeEnvironment } from 'librechat-data-provider'; import type { AgentForm } from '~/common'; -import { useLocalize } from '~/hooks'; +import { useAuthContext, useLocalize } from '~/hooks'; import { ESide } from '~/common'; export default function StatefulSessions() { const localize = useLocalize(); + const { user } = useAuthContext(); const methods = useFormContext(); const { setValue, watch } = methods; @@ -30,7 +31,11 @@ export default function StatefulSessions() { const handleChange = (value: boolean) => { setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true }); if (value && !watch('stateful_code_environment')) { - setValue('stateful_code_environment', 'user', { shouldDirty: true }); + setValue( + 'stateful_code_environment', + user?.personalization?.statefulCodeEnvironment ?? 'user', + { shouldDirty: true }, + ); } }; diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 1763eb99f7..99e8938374 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -286,6 +286,7 @@ export default function AgentPanel() { setCurrentAgentId, agent_id: current_agent_id, } = useAgentPanelContext(); + const defaultStatefulCodeEnvironment = user?.personalization?.statefulCodeEnvironment ?? 'user'; const { onSelect: onSelectAgent } = useSelectAgent(); @@ -307,7 +308,7 @@ export default function AgentPanel() { const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]); const methods = useForm({ - defaultValues: getDefaultAgentFormValues(), + defaultValues: getDefaultAgentFormValues(defaultStatefulCodeEnvironment), mode: 'onChange', }); @@ -579,6 +580,7 @@ export default function AgentPanel() { agentQuery={agentQuery} setCurrentAgentId={setCurrentAgentId} selectedAgentId={agentQuery.isInitialLoading ? null : (current_agent_id ?? null)} + defaultStatefulCodeEnvironment={defaultStatefulCodeEnvironment} /> {agent_id && ( @@ -588,7 +590,7 @@ export default function AgentPanel() { variant="outline" className="w-full justify-center" onClick={() => { - reset(getDefaultAgentFormValues()); + reset(getDefaultAgentFormValues(defaultStatefulCodeEnvironment)); setCurrentAgentId(undefined); }} disabled={agentQuery.isInitialLoading} diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 50bd290f62..4bea8c1416 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -3,8 +3,8 @@ import { EarthIcon } from 'lucide-react'; import { ControlCombobox } from '@librechat/client'; import { useFormContext, Controller } from 'react-hook-form'; import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider'; +import type { Agent, AgentCreateParams, StatefulCodeEnvironment } from 'librechat-data-provider'; import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query'; -import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { TAgentCapabilities, AgentForm } from '~/common'; import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils'; import { useLocalize, useAgentDefaultPermissionLevel } from '~/hooks'; @@ -17,11 +17,13 @@ function AgentSelect({ selectedAgentId = null, setCurrentAgentId, createMutation, + defaultStatefulCodeEnvironment, }: { selectedAgentId: string | null; agentQuery: QueryObserverResult; setCurrentAgentId: React.Dispatch>; createMutation: UseMutationResult; + defaultStatefulCodeEnvironment: StatefulCodeEnvironment; }) { const localize = useLocalize(); const lastSelectedAgent = useRef(null); @@ -180,7 +182,7 @@ function AgentSelect({ createMutation.reset(); if (!agentExists) { setCurrentAgentId(undefined); - return reset(getDefaultAgentFormValues()); + return reset(getDefaultAgentFormValues(defaultStatefulCodeEnvironment)); } setCurrentAgentId(selectedId); @@ -192,7 +194,15 @@ function AgentSelect({ resetAgentForm(agent); }, - [agents, createMutation, setCurrentAgentId, agentQuery.data, resetAgentForm, reset], + [ + agents, + createMutation, + setCurrentAgentId, + agentQuery.data, + resetAgentForm, + reset, + defaultStatefulCodeEnvironment, + ], ); useEffect(() => { diff --git a/client/src/components/SidePanel/Agents/DeleteButton.tsx b/client/src/components/SidePanel/Agents/DeleteButton.tsx index 05a33d0a08..fba6059975 100644 --- a/client/src/components/SidePanel/Agents/DeleteButton.tsx +++ b/client/src/components/SidePanel/Agents/DeleteButton.tsx @@ -14,8 +14,8 @@ import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; import { logger, getDefaultAgentFormValues } from '~/utils'; import { useDeleteAgentMutation } from '~/data-provider'; +import { useAuthContext, useLocalize } from '~/hooks'; import { isEphemeralAgent } from '~/common'; -import { useLocalize } from '~/hooks'; import store from '~/store'; function DeleteButton({ @@ -28,6 +28,7 @@ function DeleteButton({ createMutation: UseMutationResult; }) { const localize = useLocalize(); + const { user } = useAuthContext(); const { reset } = useFormContext(); const { showToast } = useToastContext(); const setConversation = useSetRecoilState(store.conversationByIndex(0)); @@ -53,7 +54,7 @@ function DeleteButton({ const firstAgent = updatedList[0] as Agent | undefined; if (!firstAgent) { setCurrentAgentId(undefined); - reset(getDefaultAgentFormValues()); + reset(getDefaultAgentFormValues(user?.personalization?.statefulCodeEnvironment ?? 'user')); setConversation((prev) => (prev ? { ...prev, agent_id: '' } : prev)); return; } diff --git a/client/src/data-provider/Auth/mutations.ts b/client/src/data-provider/Auth/mutations.ts index 4ba79b94cb..66db9bb23f 100644 --- a/client/src/data-provider/Auth/mutations.ts +++ b/client/src/data-provider/Auth/mutations.ts @@ -1,7 +1,7 @@ import { useResetRecoilState, useSetRecoilState } from 'recoil'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { MutationKeys, QueryKeys, dataService, request } from 'librechat-data-provider'; -import type { UseMutationResult } from '@tanstack/react-query'; +import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import type * as t from 'librechat-data-provider'; import useClearStates from '~/hooks/Config/useClearStates'; import { clearAllConversationStorage } from '~/utils'; @@ -92,6 +92,43 @@ export const useDeleteUserMutation = ( }); }; +export const useUpdateUserPreferencesMutation = ( + options?: UseMutationOptions< + t.TUpdateUserPreferencesResponse, + Error, + t.TUpdateUserPreferencesRequest + >, +): UseMutationResult< + t.TUpdateUserPreferencesResponse, + Error, + t.TUpdateUserPreferencesRequest, + unknown +> => { + const queryClient = useQueryClient(); + return useMutation( + [MutationKeys.updateUserPreferences], + (preferences: t.TUpdateUserPreferencesRequest) => + dataService.updateUserPreferences(preferences), + { + ...options, + onSuccess: (data, ...args) => { + queryClient.setQueryData([QueryKeys.user], (user) => + user + ? { + ...user, + personalization: { + ...user.personalization, + ...data.preferences, + }, + } + : user, + ); + options?.onSuccess?.(data, ...args); + }, + }, + ); +}; + export const useEnableTwoFactorMutation = (): UseMutationResult< t.TEnable2FAResponse, unknown, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b9c384d598..dfd4132ec6 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1920,6 +1920,7 @@ "com_ui_settings_section_langfuse": "Langfuse", "com_ui_settings_section_layout": "Layout", "com_ui_settings_section_memory": "Memory", + "com_ui_settings_section_code_execution": "Code execution", "com_ui_settings_section_messages": "Messages", "com_ui_settings_section_profile": "Profile", "com_ui_settings_section_prompts": "Prompts", @@ -2066,6 +2067,8 @@ "com_ui_stateful_code_environment_user": "User workspace (recommended)", "com_ui_stateful_code_environment_agent_user": "Agent + user workspace", "com_ui_stateful_code_environment_conversation": "Conversation workspace", + "com_ui_default_stateful_workspace": "Default stateful workspace", + "com_ui_default_stateful_workspace_description": "Applied when you create a new agent. Existing agents keep their current workspace scope.", "com_ui_status_prefix": "Status:", "com_ui_steer": "Steer", "com_ui_steer_already_applied": "That steering message already reached the agent, so it was left in the response", diff --git a/client/src/utils/forms.spec.tsx b/client/src/utils/forms.spec.tsx new file mode 100644 index 0000000000..ff70e1a292 --- /dev/null +++ b/client/src/utils/forms.spec.tsx @@ -0,0 +1,15 @@ +import { getDefaultAgentFormValues } from './forms'; + +describe('getDefaultAgentFormValues', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('uses the scalable user workspace by default', () => { + expect(getDefaultAgentFormValues().stateful_code_environment).toBe('user'); + }); + + it('seeds a new agent with the user workspace preference', () => { + expect(getDefaultAgentFormValues('agent-user').stateful_code_environment).toBe('agent-user'); + }); +}); diff --git a/client/src/utils/forms.tsx b/client/src/utils/forms.tsx index c73aafb03d..746d4659f7 100644 --- a/client/src/utils/forms.tsx +++ b/client/src/utils/forms.tsx @@ -7,7 +7,7 @@ import { LocalStorageKeys, defaultAgentFormValues, } from 'librechat-data-provider'; -import type { Agent, TFile } from 'librechat-data-provider'; +import type { Agent, TFile, StatefulCodeEnvironment } from 'librechat-data-provider'; import type { DropdownValueSetter, TAgentOption, ExtendedFile } from '~/common'; /** @@ -48,8 +48,11 @@ export const createProviderOption = (provider: string) => ({ * Gets default agent form values with localStorage values for model and provider. * This is used to initialize agent forms with the last used model and provider. **/ -export const getDefaultAgentFormValues = () => ({ +export const getDefaultAgentFormValues = ( + statefulCodeEnvironment: StatefulCodeEnvironment = 'user', +) => ({ ...defaultAgentFormValues, + stateful_code_environment: statefulCodeEnvironment, model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '', provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''), avatar_file: null, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 1829f34e79..1cc204b1e7 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -60,6 +60,8 @@ export * from './conversations'; /* Skills */ export * from './skills'; export * from './favorites'; +/* User */ +export * from './user'; /* Agent Plugins */ export * from './plugins'; /* Endpoints */ diff --git a/packages/api/src/user/index.ts b/packages/api/src/user/index.ts new file mode 100644 index 0000000000..a17a3da115 --- /dev/null +++ b/packages/api/src/user/index.ts @@ -0,0 +1 @@ +export * from './preferences'; diff --git a/packages/api/src/user/preferences.spec.ts b/packages/api/src/user/preferences.spec.ts new file mode 100644 index 0000000000..d33ff364fc --- /dev/null +++ b/packages/api/src/user/preferences.spec.ts @@ -0,0 +1,98 @@ +import type { IUser } from '@librechat/data-schemas'; +import type { Request, Response } from 'express'; +import { createUserPreferencesHandler } from './preferences'; + +interface MockResponse extends Partial { + statusCode: number; + body?: object; + status: jest.Mock; + json: jest.Mock; +} + +function createResponse(): MockResponse { + const response: MockResponse = { + statusCode: 200, + status: jest.fn((statusCode: number) => { + response.statusCode = statusCode; + return response; + }), + json: jest.fn((body: object) => { + response.body = body; + return response; + }), + }; + return response; +} + +function createRequest(body: object, authenticated = true): Request & { user?: Partial } { + return { + body, + user: authenticated ? { id: 'user-1' } : undefined, + } as Request & { user?: Partial }; +} + +describe('createUserPreferencesHandler', () => { + it.each(['user', 'agent-user', 'conversation'] as const)( + 'persists the %s stateful workspace default', + async (statefulCodeEnvironment) => { + const updateStatefulCodeEnvironment = jest.fn().mockResolvedValue({ + personalization: { statefulCodeEnvironment }, + }); + const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment }); + const response = createResponse(); + + await handler( + createRequest({ statefulCodeEnvironment }) as Parameters[0], + response as Response, + ); + + expect(updateStatefulCodeEnvironment).toHaveBeenCalledWith('user-1', statefulCodeEnvironment); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + updated: true, + preferences: { statefulCodeEnvironment }, + }); + }, + ); + + it('rejects an invalid stateful workspace default', async () => { + const updateStatefulCodeEnvironment = jest.fn(); + const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment }); + const response = createResponse(); + + await handler( + createRequest({ statefulCodeEnvironment: 'agent' }) as Parameters[0], + response as Response, + ); + + expect(updateStatefulCodeEnvironment).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(400); + }); + + it('requires an authenticated user', async () => { + const updateStatefulCodeEnvironment = jest.fn(); + const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment }); + const response = createResponse(); + + await handler( + createRequest({ statefulCodeEnvironment: 'user' }, false) as Parameters[0], + response as Response, + ); + + expect(updateStatefulCodeEnvironment).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(401); + }); + + it('returns not found when the user no longer exists', async () => { + const updateStatefulCodeEnvironment = jest.fn().mockResolvedValue(null); + const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment }); + const response = createResponse(); + + await handler( + createRequest({ statefulCodeEnvironment: 'user' }) as Parameters[0], + response as Response, + ); + + expect(response.statusCode).toBe(404); + }); +}); diff --git a/packages/api/src/user/preferences.ts b/packages/api/src/user/preferences.ts new file mode 100644 index 0000000000..780db1f9d0 --- /dev/null +++ b/packages/api/src/user/preferences.ts @@ -0,0 +1,60 @@ +import { logger } from '@librechat/data-schemas'; +import { STATEFUL_CODE_ENVIRONMENTS } from 'librechat-data-provider'; +import type { StatefulCodeEnvironment } from 'librechat-data-provider'; +import type { IUser } from '@librechat/data-schemas'; +import type { Request, Response } from 'express'; + +interface UserPreferencesBody { + statefulCodeEnvironment?: string; +} + +function isStatefulCodeEnvironment(value: string): value is StatefulCodeEnvironment { + return STATEFUL_CODE_ENVIRONMENTS.some((environment) => environment === value); +} + +type UserPreferencesRequest = Request & { + user?: IUser; +}; + +export interface UserPreferencesHandlerDeps { + updateStatefulCodeEnvironment: ( + userId: string, + environment: StatefulCodeEnvironment, + ) => Promise; +} + +export function createUserPreferencesHandler( + deps: UserPreferencesHandlerDeps, +): (req: UserPreferencesRequest, res: Response) => Promise { + return async (req: UserPreferencesRequest, res: Response): Promise => { + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const environment = req.body?.statefulCodeEnvironment; + if (typeof environment !== 'string' || !isStatefulCodeEnvironment(environment)) { + return res.status(400).json({ + message: `statefulCodeEnvironment must be one of: ${STATEFUL_CODE_ENVIRONMENTS.join(', ')}`, + }); + } + + try { + const updatedUser = await deps.updateStatefulCodeEnvironment(userId, environment); + if (!updatedUser) { + return res.status(404).json({ message: 'User not found' }); + } + + return res.status(200).json({ + updated: true, + preferences: { + statefulCodeEnvironment: + updatedUser.personalization?.statefulCodeEnvironment ?? environment, + }, + }); + } catch (error) { + logger.error('[UserPreferences] Error updating preferences:', error); + return res.status(500).json({ message: 'Failed to update user preferences' }); + } + }; +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index a62e917f4c..2cfcdef0e0 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -42,6 +42,7 @@ const buildQuery = (params: Record): string => { export const health = () => `${BASE_URL}/health`; export const user = () => `${BASE_URL}/api/user`; +export const userPreferences = () => `${user()}/preferences`; export const balance = () => `${BASE_URL}/api/balance`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 0fd38ba7ee..df65ee26db 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -172,6 +172,12 @@ export function getUser(): Promise { return request.get(endpoints.user()); } +export function updateUserPreferences( + preferences: t.TUpdateUserPreferencesRequest, +): Promise { + return request.patch(endpoints.userPreferences(), preferences); +} + export function getUserBalance(): Promise { return request.get(endpoints.balance()); } diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 0f0292910d..6d62a4e743 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -119,6 +119,7 @@ export enum MutationKeys { deleteAgentAction = 'deleteAgentAction', revertAgentVersion = 'revertAgentVersion', deleteUser = 'deleteUser', + updateUserPreferences = 'updateUserPreferences', updateRole = 'updateRole', enableTwoFactor = 'enableTwoFactor', verifyTwoFactor = 'verifyTwoFactor', diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 7828bcd04f..5f3d81ffd9 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -10,7 +10,7 @@ import type { ReasoningResponseKey, ReasoningParameterFormat, } from './schemas'; -import type { Agent, EToolResources } from './types/assistants'; +import type { Agent, EToolResources, StatefulCodeEnvironment } from './types/assistants'; import type { RefillIntervalUnit } from './balance'; import type { SettingDefinition } from './generate'; import type { TMinimalFeedback } from './feedback'; @@ -293,11 +293,21 @@ export type TUser = { backupCodes?: TBackupCode[]; personalization?: { memories?: boolean; + statefulCodeEnvironment?: StatefulCodeEnvironment; }; createdAt: string; updatedAt: string; }; +export type TUpdateUserPreferencesRequest = { + statefulCodeEnvironment: StatefulCodeEnvironment; +}; + +export type TUpdateUserPreferencesResponse = { + updated: boolean; + preferences: TUpdateUserPreferencesRequest; +}; + export type TGetConversationsResponse = { conversations: TConversation[]; pageNumber: string; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index b4a705d1be..fadda72394 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -538,7 +538,8 @@ export enum AnnotationTypes { FILE_PATH = 'file_path', } -export type StatefulCodeEnvironment = 'user' | 'agent-user' | 'conversation'; +export const STATEFUL_CODE_ENVIRONMENTS = ['user', 'agent-user', 'conversation'] as const; +export type StatefulCodeEnvironment = (typeof STATEFUL_CODE_ENVIRONMENTS)[number]; export enum StepStatus { IN_PROGRESS = 'in_progress', diff --git a/packages/data-schemas/src/methods/user.methods.spec.ts b/packages/data-schemas/src/methods/user.methods.spec.ts index f320c49e4a..1270a03c5b 100644 --- a/packages/data-schemas/src/methods/user.methods.spec.ts +++ b/packages/data-schemas/src/methods/user.methods.spec.ts @@ -106,6 +106,27 @@ describe('User schema indexes', () => { }); }); +describe('User personalization', () => { + test('defaults new users to the shared user workspace', async () => { + const user = await User.create({ + email: 'stateful-default@example.com', + provider: 'local', + }); + + expect(user.personalization?.statefulCodeEnvironment).toBe('user'); + }); + + test('rejects unsupported stateful workspace defaults', async () => { + await expect( + User.create({ + email: 'invalid-stateful-default@example.com', + provider: 'local', + personalization: { statefulCodeEnvironment: 'agent' }, + }), + ).rejects.toThrow(); + }); +}); + describe('User Methods - Database Tests', () => { describe('findUser', () => { test('should find user by exact email', async () => { @@ -829,6 +850,57 @@ describe('User Methods - Database Tests', () => { }); }); + describe('updateUserStatefulCodeEnvironment', () => { + test('updates the workspace default without changing memory preferences', async () => { + const user = await User.create({ + email: 'stateful-preference@example.com', + provider: 'local', + personalization: { memories: false, statefulCodeEnvironment: 'user' }, + }); + + const updated = await methods.updateUserStatefulCodeEnvironment( + user._id?.toString() ?? '', + 'agent-user', + ); + + expect(updated?.personalization).toMatchObject({ + memories: false, + statefulCodeEnvironment: 'agent-user', + }); + }); + + test('returns null for a missing user', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + + await expect( + methods.updateUserStatefulCodeEnvironment(userId, 'conversation'), + ).resolves.toBeNull(); + }); + + test('invalidates cached auth user documents', async () => { + enableAuthUserDocCache(); + const user = await User.create({ + email: 'cached-stateful-preference@example.com', + provider: 'openid', + }); + const userId = user._id?.toString() ?? ''; + const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${userId}`; + const cache = { + get: jest.fn().mockResolvedValue(['auth-cache-key-a']), + delete: jest.fn().mockResolvedValue(true), + }; + const methodsWithCache = createUserMethods(mongoose, { + getCache: jest.fn().mockReturnValue(cache), + }); + + await methodsWithCache.updateUserStatefulCodeEnvironment(userId, 'conversation'); + + expect(cache.get).toHaveBeenCalledWith(indexKey); + expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-a'); + expect(cache.delete).toHaveBeenCalledWith(indexKey); + }); + }); + describe('Email Normalization Edge Cases', () => { test('should handle email with multiple spaces', async () => { await User.create({ diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 35b5be6cae..d03eb7fed6 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -3,6 +3,7 @@ import { AUTH_USER_DOC_BY_ID_PREFIX, CacheKeys, type RefillIntervalUnit, + type StatefulCodeEnvironment, } from 'librechat-data-provider'; import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types'; import type { CacheStore } from '~/types'; @@ -93,6 +94,7 @@ export function createUserMethods( termsAccepted?: boolean; personalization?: { memories?: boolean; + statefulCodeEnvironment?: import('librechat-data-provider').StatefulCodeEnvironment; }; favorites?: import('librechat-data-provider').TUserFavorite[]; skillStates?: Record; @@ -123,6 +125,10 @@ export function createUserMethods( action: 'install' | 'uninstall', ) => Promise; toggleUserMemories: (userId: string, memoriesEnabled: boolean) => Promise; + updateUserStatefulCodeEnvironment: ( + userId: string, + environment: StatefulCodeEnvironment, + ) => Promise; } { /** * Normalizes email fields in search criteria to lowercase and trimmed. @@ -427,6 +433,22 @@ export function createUserMethods( return updated; } + async function updateUserStatefulCodeEnvironment( + userId: string, + environment: StatefulCodeEnvironment, + ): Promise { + const User = mongoose.models.User; + const updated = await User.findByIdAndUpdate( + userId, + { $set: { 'personalization.statefulCodeEnvironment': environment } }, + { new: true, runValidators: true }, + ).lean(); + if (updated) { + await invalidateAuthUserDocCache(userId); + } + return updated; + } + /** * Search for users by pattern matching on name, email, or username (case-insensitive) * @param searchPattern - The pattern to search for @@ -484,6 +506,7 @@ export function createUserMethods( termsAccepted?: boolean; personalization?: { memories?: boolean; + statefulCodeEnvironment?: import('librechat-data-provider').StatefulCodeEnvironment; }; favorites?: import('librechat-data-provider').TUserFavorite[]; skillStates?: Record; @@ -607,6 +630,7 @@ export function createUserMethods( deleteUserById, updateUserPlugins, toggleUserMemories, + updateUserStatefulCodeEnvironment, }; } diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 706c73403b..309ad54aa9 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -1,5 +1,5 @@ import { Schema } from 'mongoose'; -import { SystemRoles } from 'librechat-data-provider'; +import { SystemRoles, STATEFUL_CODE_ENVIRONMENTS } from 'librechat-data-provider'; import { IUser } from '~/types'; // Session sub-schema @@ -137,6 +137,11 @@ const userSchema: Schema = new Schema( type: Boolean, default: true, }, + statefulCodeEnvironment: { + type: String, + enum: STATEFUL_CODE_ENVIRONMENTS, + default: 'user', + }, }, default: {}, }, diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index 5b302ad275..2b91252a7a 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -1,4 +1,8 @@ -import type { RefillIntervalUnit, TUserFavorite } from 'librechat-data-provider'; +import type { + TUserFavorite, + RefillIntervalUnit, + StatefulCodeEnvironment, +} from 'librechat-data-provider'; import type { Document, Types } from 'mongoose'; import { CursorPaginationParams } from '~/common'; @@ -51,6 +55,7 @@ export interface IUser extends Document { termsAcceptedAt?: Date | null; personalization?: { memories?: boolean; + statefulCodeEnvironment?: StatefulCodeEnvironment; }; favorites?: TUserFavorite[]; /** Per-skill active/inactive overrides. Key = skillId, value = active state. */ @@ -97,6 +102,7 @@ export interface UpdateUserRequest { termsAcceptedAt?: Date | null; personalization?: { memories?: boolean; + statefulCodeEnvironment?: StatefulCodeEnvironment; }; skillStates?: Record; }