mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🥚 refactor: Default Agents to Preferred Stateful Workspace Scope (#14908)
* feat: add user default for stateful agent workspaces * style: sort stateful workspace imports
This commit is contained in:
parent
f829aca9fb
commit
485abef3fa
29 changed files with 570 additions and 18 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<StatefulCodeEnvironment>(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 (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<label id="default-stateful-workspace-label" htmlFor="default-stateful-workspace">
|
||||
{localize('com_ui_default_stateful_workspace')}
|
||||
</label>
|
||||
<p id="default-stateful-workspace-description" className="mt-1 text-xs text-text-secondary">
|
||||
{localize('com_ui_default_stateful_workspace_description')}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={environment} onValueChange={handleChange} disabled={mutation.isLoading}>
|
||||
<SelectTrigger
|
||||
id="default-stateful-workspace"
|
||||
className="w-full shrink-0 sm:w-[220px]"
|
||||
aria-labelledby="default-stateful-workspace-label"
|
||||
aria-describedby="default-stateful-workspace-description"
|
||||
data-testid="default-stateful-workspace"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">{localize('com_ui_stateful_code_environment_user')}</SelectItem>
|
||||
<SelectItem value="agent-user">
|
||||
{localize('com_ui_stateful_code_environment_agent_user')}
|
||||
</SelectItem>
|
||||
<SelectItem value="conversation">
|
||||
{localize('com_ui_stateful_code_environment_conversation')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ const ctx: SettingsContextValue = {
|
|||
balanceEnabled: false,
|
||||
hasAnyPersonalizationFeature: false,
|
||||
hasMemoryOptOut: false,
|
||||
hasStatefulCodeSessions: false,
|
||||
hasRemoteAgents: false,
|
||||
hasUserProvidedEndpoints: false,
|
||||
hasMultiConvo: false,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) => (
|
||||
<select
|
||||
aria-label="workspace-default"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: ReactNode }) => children,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => children,
|
||||
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
|
||||
<option value={value}>{children}</option>
|
||||
),
|
||||
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(<StatefulWorkspaceDefault />);
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'workspace-default' })).toHaveValue('agent-user');
|
||||
});
|
||||
|
||||
it('persists a new default for future agents', async () => {
|
||||
render(<StatefulWorkspaceDefault />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'workspace-default' }),
|
||||
'conversation',
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({ statefulCodeEnvironment: 'conversation' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>(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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<AgentForm>();
|
||||
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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AgentForm>({
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
{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}
|
||||
|
|
|
|||
|
|
@ -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<Agent>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
createMutation: UseMutationResult<Agent, Error, AgentCreateParams>;
|
||||
defaultStatefulCodeEnvironment: StatefulCodeEnvironment;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const lastSelectedAgent = useRef<string | null>(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(() => {
|
||||
|
|
|
|||
|
|
@ -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<Agent, Error, AgentCreateParams>;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<t.TUpdateUserPreferencesResponse, Error, t.TUpdateUserPreferencesRequest>(
|
||||
[MutationKeys.updateUserPreferences],
|
||||
(preferences: t.TUpdateUserPreferencesRequest) =>
|
||||
dataService.updateUserPreferences(preferences),
|
||||
{
|
||||
...options,
|
||||
onSuccess: (data, ...args) => {
|
||||
queryClient.setQueryData<t.TUser>([QueryKeys.user], (user) =>
|
||||
user
|
||||
? {
|
||||
...user,
|
||||
personalization: {
|
||||
...user.personalization,
|
||||
...data.preferences,
|
||||
},
|
||||
}
|
||||
: user,
|
||||
);
|
||||
options?.onSuccess?.(data, ...args);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useEnableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TEnable2FAResponse,
|
||||
unknown,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
client/src/utils/forms.spec.tsx
Normal file
15
client/src/utils/forms.spec.tsx
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export * from './conversations';
|
|||
/* Skills */
|
||||
export * from './skills';
|
||||
export * from './favorites';
|
||||
/* User */
|
||||
export * from './user';
|
||||
/* Agent Plugins */
|
||||
export * from './plugins';
|
||||
/* Endpoints */
|
||||
|
|
|
|||
1
packages/api/src/user/index.ts
Normal file
1
packages/api/src/user/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './preferences';
|
||||
98
packages/api/src/user/preferences.spec.ts
Normal file
98
packages/api/src/user/preferences.spec.ts
Normal file
|
|
@ -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<Response> {
|
||||
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<IUser> } {
|
||||
return {
|
||||
body,
|
||||
user: authenticated ? { id: 'user-1' } : undefined,
|
||||
} as Request & { user?: Partial<IUser> };
|
||||
}
|
||||
|
||||
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<typeof handler>[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<typeof handler>[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<typeof handler>[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<typeof handler>[0],
|
||||
response as Response,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
60
packages/api/src/user/preferences.ts
Normal file
60
packages/api/src/user/preferences.ts
Normal file
|
|
@ -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<unknown, unknown, UserPreferencesBody> & {
|
||||
user?: IUser;
|
||||
};
|
||||
|
||||
export interface UserPreferencesHandlerDeps {
|
||||
updateStatefulCodeEnvironment: (
|
||||
userId: string,
|
||||
environment: StatefulCodeEnvironment,
|
||||
) => Promise<IUser | null>;
|
||||
}
|
||||
|
||||
export function createUserPreferencesHandler(
|
||||
deps: UserPreferencesHandlerDeps,
|
||||
): (req: UserPreferencesRequest, res: Response) => Promise<Response> {
|
||||
return async (req: UserPreferencesRequest, res: Response): Promise<Response> => {
|
||||
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' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ const buildQuery = (params: Record<string, unknown>): 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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,12 @@ export function getUser(): Promise<t.TUser> {
|
|||
return request.get(endpoints.user());
|
||||
}
|
||||
|
||||
export function updateUserPreferences(
|
||||
preferences: t.TUpdateUserPreferencesRequest,
|
||||
): Promise<t.TUpdateUserPreferencesResponse> {
|
||||
return request.patch(endpoints.userPreferences(), preferences);
|
||||
}
|
||||
|
||||
export function getUserBalance(): Promise<t.TBalanceResponse> {
|
||||
return request.get(endpoints.balance());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export enum MutationKeys {
|
|||
deleteAgentAction = 'deleteAgentAction',
|
||||
revertAgentVersion = 'revertAgentVersion',
|
||||
deleteUser = 'deleteUser',
|
||||
updateUserPreferences = 'updateUserPreferences',
|
||||
updateRole = 'updateRole',
|
||||
enableTwoFactor = 'enableTwoFactor',
|
||||
verifyTwoFactor = 'verifyTwoFactor',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>;
|
||||
|
|
@ -123,6 +125,10 @@ export function createUserMethods(
|
|||
action: 'install' | 'uninstall',
|
||||
) => Promise<IUser | null>;
|
||||
toggleUserMemories: (userId: string, memoriesEnabled: boolean) => Promise<IUser | null>;
|
||||
updateUserStatefulCodeEnvironment: (
|
||||
userId: string,
|
||||
environment: StatefulCodeEnvironment,
|
||||
) => Promise<IUser | null>;
|
||||
} {
|
||||
/**
|
||||
* 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<IUser | null> {
|
||||
const User = mongoose.models.User;
|
||||
const updated = await User.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $set: { 'personalization.statefulCodeEnvironment': environment } },
|
||||
{ new: true, runValidators: true },
|
||||
).lean<IUser>();
|
||||
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<string, boolean>;
|
||||
|
|
@ -607,6 +630,7 @@ export function createUserMethods(
|
|||
deleteUserById,
|
||||
updateUserPlugins,
|
||||
toggleUserMemories,
|
||||
updateUserStatefulCodeEnvironment,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IUser> = new Schema<IUser>(
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
statefulCodeEnvironment: {
|
||||
type: String,
|
||||
enum: STATEFUL_CODE_ENVIRONMENTS,
|
||||
default: 'user',
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue