diff --git a/.env.example b/.env.example index af99302ee..103728fe0 100644 --- a/.env.example +++ b/.env.example @@ -431,10 +431,10 @@ ALLOW_SHARED_LINKS_PUBLIC=true # Static File Cache Control # #==============================# -# Leave commented out to use default of 1 month for max-age and 1 week for s-maxage +# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age # NODE_ENV must be set to production for these to take effect -# STATIC_CACHE_MAX_AGE=604800 -# STATIC_CACHE_S_MAX_AGE=259200 +# STATIC_CACHE_MAX_AGE=172800 +# STATIC_CACHE_S_MAX_AGE=86400 # If you have another service in front of your LibreChat doing compression, disable express based compression here # DISABLE_COMPRESSION=true diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 483427e46..a28c65d10 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -94,7 +94,8 @@ class AnthropicClient extends BaseClient { const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic); this.isClaude3 = modelMatch.startsWith('claude-3'); this.isLegacyOutput = !modelMatch.startsWith('claude-3-5-sonnet'); - this.supportsCacheControl = this.checkPromptCacheSupport(modelMatch); + this.supportsCacheControl = + this.options.promptCache && this.checkPromptCacheSupport(modelMatch); if ( this.isLegacyOutput && @@ -821,6 +822,7 @@ class AnthropicClient extends BaseClient { maxContextTokens: this.options.maxContextTokens, promptPrefix: this.options.promptPrefix, modelLabel: this.options.modelLabel, + promptCache: this.options.promptCache, resendFiles: this.options.resendFiles, iconURL: this.options.iconURL, greeting: this.options.greeting, diff --git a/api/app/clients/specs/AnthropicClient.test.js b/api/app/clients/specs/AnthropicClient.test.js index 09ae9a399..29267de46 100644 --- a/api/app/clients/specs/AnthropicClient.test.js +++ b/api/app/clients/specs/AnthropicClient.test.js @@ -206,7 +206,7 @@ describe('AnthropicClient', () => { const modelOptions = { model: 'claude-3-5-sonnet-20240307', }; - client.setOptions({ modelOptions }); + client.setOptions({ modelOptions, promptCache: true }); const anthropicClient = client.getClient(modelOptions); expect(anthropicClient._options.defaultHeaders).toBeDefined(); expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta'); @@ -220,7 +220,7 @@ describe('AnthropicClient', () => { const modelOptions = { model: 'claude-3-haiku-2028', }; - client.setOptions({ modelOptions }); + client.setOptions({ modelOptions, promptCache: true }); const anthropicClient = client.getClient(modelOptions); expect(anthropicClient._options.defaultHeaders).toBeDefined(); expect(anthropicClient._options.defaultHeaders).toHaveProperty('anthropic-beta'); diff --git a/api/models/Role.js b/api/models/Role.js index f4015a70e..d21efee3b 100644 --- a/api/models/Role.js +++ b/api/models/Role.js @@ -76,46 +76,57 @@ const permissionSchemas = { }; /** - * Updates access permissions for a specific role and permission type. + * Updates access permissions for a specific role and multiple permission types. * @param {SystemRoles} roleName - The role to update. - * @param {PermissionTypes} permissionType - The type of permission to update. - * @param {Object.} permissions - Permissions to update and their values. + * @param {Object.>} permissionsUpdate - Permissions to update and their values. */ -async function updateAccessPermissions(roleName, permissionType, _permissions) { - const permissions = removeNullishValues(_permissions); - if (Object.keys(permissions).length === 0) { +async function updateAccessPermissions(roleName, permissionsUpdate) { + const updates = {}; + for (const [permissionType, permissions] of Object.entries(permissionsUpdate)) { + if (permissionSchemas[permissionType]) { + updates[permissionType] = removeNullishValues(permissions); + } + } + + if (Object.keys(updates).length === 0) { return; } try { const role = await getRoleByName(roleName); - if (!role || !permissionSchemas[permissionType]) { + if (!role) { return; } - await updateRoleByName(roleName, { - [permissionType]: { - ...role[permissionType], - ...permissionSchemas[permissionType].partial().parse(permissions), - }, - }); + const updatedPermissions = {}; + let hasChanges = false; - Object.entries(permissions).forEach(([permission, value]) => - logger.info( - `Updated '${roleName}' role ${permissionType} '${permission}' permission to: ${value}`, - ), - ); + for (const [permissionType, permissions] of Object.entries(updates)) { + const currentPermissions = role[permissionType] || {}; + updatedPermissions[permissionType] = { ...currentPermissions }; + + for (const [permission, value] of Object.entries(permissions)) { + if (currentPermissions[permission] !== value) { + updatedPermissions[permissionType][permission] = value; + hasChanges = true; + logger.info( + `Updating '${roleName}' role ${permissionType} '${permission}' permission from ${currentPermissions[permission]} to: ${value}`, + ); + } + } + } + + if (hasChanges) { + await updateRoleByName(roleName, updatedPermissions); + logger.info(`Updated '${roleName}' role permissions`); + } else { + logger.info(`No changes needed for '${roleName}' role permissions`); + } } catch (error) { - logger.error(`Failed to update ${roleName} role ${permissionType} permissions:`, error); + logger.error(`Failed to update ${roleName} role permissions:`, error); } } -const updatePromptsAccess = (roleName, permissions) => - updateAccessPermissions(roleName, PermissionTypes.PROMPTS, permissions); - -const updateBookmarksAccess = (roleName, permissions) => - updateAccessPermissions(roleName, PermissionTypes.BOOKMARKS, permissions); - /** * Initialize default roles in the system. * Creates the default roles (ADMIN, USER) if they don't exist in the database. @@ -138,6 +149,5 @@ module.exports = { getRoleByName, initializeRoles, updateRoleByName, - updatePromptsAccess, - updateBookmarksAccess, + updateAccessPermissions, }; diff --git a/api/models/Role.spec.js b/api/models/Role.spec.js new file mode 100644 index 000000000..c183b9d1c --- /dev/null +++ b/api/models/Role.spec.js @@ -0,0 +1,197 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { SystemRoles, PermissionTypes } = require('librechat-data-provider'); +const Role = require('~/models/schema/roleSchema'); +const { updateAccessPermissions } = require('~/models/Role'); +const getLogStores = require('~/cache/getLogStores'); + +// Mock the cache +jest.mock('~/cache/getLogStores', () => { + return jest.fn().mockReturnValue({ + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }); +}); + +let mongoServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await Role.deleteMany({}); + getLogStores.mockClear(); +}); + +describe('updateAccessPermissions', () => { + it('should update permissions when changes are needed', async () => { + await new Role({ + name: SystemRoles.USER, + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + }).save(); + + await updateAccessPermissions(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: true, + }, + }); + + const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean(); + expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({ + CREATE: true, + USE: true, + SHARED_GLOBAL: true, + }); + }); + + it('should not update permissions when no changes are needed', async () => { + await new Role({ + name: SystemRoles.USER, + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + }).save(); + + await updateAccessPermissions(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + }); + + const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean(); + expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({ + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }); + }); + + it('should handle non-existent roles', async () => { + await updateAccessPermissions('NON_EXISTENT_ROLE', { + [PermissionTypes.PROMPTS]: { + CREATE: true, + }, + }); + + const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' }); + expect(role).toBeNull(); + }); + + it('should update only specified permissions', async () => { + await new Role({ + name: SystemRoles.USER, + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + }).save(); + + await updateAccessPermissions(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { + SHARED_GLOBAL: true, + }, + }); + + const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean(); + expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({ + CREATE: true, + USE: true, + SHARED_GLOBAL: true, + }); + }); + + it('should handle partial updates', async () => { + await new Role({ + name: SystemRoles.USER, + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + }).save(); + + await updateAccessPermissions(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { + USE: false, + }, + }); + + const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean(); + expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({ + CREATE: true, + USE: false, + SHARED_GLOBAL: false, + }); + }); + + it('should update multiple permission types at once', async () => { + await new Role({ + name: SystemRoles.USER, + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + [PermissionTypes.BOOKMARKS]: { + USE: true, + }, + }).save(); + + await updateAccessPermissions(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true }, + [PermissionTypes.BOOKMARKS]: { USE: false }, + }); + + const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean(); + expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({ + CREATE: true, + USE: false, + SHARED_GLOBAL: true, + }); + expect(updatedRole[PermissionTypes.BOOKMARKS]).toEqual({ + USE: false, + }); + }); + + it('should handle updates for a single permission type', async () => { + await new Role({ + name: SystemRoles.USER, + [PermissionTypes.PROMPTS]: { + CREATE: true, + USE: true, + SHARED_GLOBAL: false, + }, + }).save(); + + await updateAccessPermissions(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true }, + }); + + const updatedRole = await Role.findOne({ name: SystemRoles.USER }).lean(); + expect(updatedRole[PermissionTypes.PROMPTS]).toEqual({ + CREATE: true, + USE: false, + SHARED_GLOBAL: true, + }); + }); +}); diff --git a/api/models/schema/defaults.js b/api/models/schema/defaults.js index 3ce718f22..4a99a6837 100644 --- a/api/models/schema/defaults.js +++ b/api/models/schema/defaults.js @@ -74,6 +74,10 @@ const conversationPreset = { resendImages: { type: Boolean, }, + /* Anthropic only */ + promptCache: { + type: Boolean, + }, // files resendFiles: { type: Boolean, diff --git a/api/server/index.js b/api/server/index.js index 83922f025..b39116fbb 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -16,9 +16,9 @@ const validateImageRequest = require('./middleware/validateImageRequest'); const errorController = require('./controllers/ErrorController'); const configureSocialLogins = require('./socialLogins'); const AppService = require('./services/AppService'); +const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); -const staticCache = require('./utils/staticCache'); const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {}; @@ -51,7 +51,7 @@ const startServer = async () => { app.set('trust proxy', 1); /* trust first proxy */ app.use(cors()); - if (DISABLE_COMPRESSION !== 'true') { + if (!isEnabled(DISABLE_COMPRESSION)) { app.use(compression()); } diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js index dcf2a7bf2..802f61a9c 100644 --- a/api/server/services/AppService.interface.spec.js +++ b/api/server/services/AppService.interface.spec.js @@ -1,6 +1,6 @@ jest.mock('~/models/Role', () => ({ initializeRoles: jest.fn(), - updatePromptsAccess: jest.fn(), + updateAccessPermissions: jest.fn(), getRoleByName: jest.fn(), updateRoleByName: jest.fn(), })); @@ -30,7 +30,7 @@ jest.mock('./start/checks', () => ({ const AppService = require('./AppService'); const { loadDefaultInterface } = require('./start/interface'); -describe('AppService interface.prompts configuration', () => { +describe('AppService interface configuration', () => { let app; let mockLoadCustomConfig; @@ -41,33 +41,47 @@ describe('AppService interface.prompts configuration', () => { mockLoadCustomConfig = require('./Config/loadCustomConfig'); }); - it('should set prompts to true when loadDefaultInterface returns true', async () => { + it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => { mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ prompts: true }); + loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true }); await AppService(app); expect(app.locals.interfaceConfig.prompts).toBe(true); + expect(app.locals.interfaceConfig.bookmarks).toBe(true); expect(loadDefaultInterface).toHaveBeenCalled(); }); - it('should set prompts to false when loadDefaultInterface returns false', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: false }); + it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => { + mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } }); + loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false }); await AppService(app); expect(app.locals.interfaceConfig.prompts).toBe(false); + expect(app.locals.interfaceConfig.bookmarks).toBe(false); expect(loadDefaultInterface).toHaveBeenCalled(); }); - it('should not set prompts when loadDefaultInterface returns undefined', async () => { + it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => { mockLoadCustomConfig.mockResolvedValue({}); loadDefaultInterface.mockResolvedValue({}); await AppService(app); expect(app.locals.interfaceConfig.prompts).toBeUndefined(); + expect(app.locals.interfaceConfig.bookmarks).toBeUndefined(); + expect(loadDefaultInterface).toHaveBeenCalled(); + }); + + it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => { + mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } }); + loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false }); + + await AppService(app); + + expect(app.locals.interfaceConfig.prompts).toBe(true); + expect(app.locals.interfaceConfig.bookmarks).toBe(false); expect(loadDefaultInterface).toHaveBeenCalled(); }); }); diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 90ca6f975..61ac80fc6 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -23,8 +23,7 @@ jest.mock('./Files/Firebase/initialize', () => ({ })); jest.mock('~/models/Role', () => ({ initializeRoles: jest.fn(), - updatePromptsAccess: jest.fn(), - updateBookmarksAccess: jest.fn(), + updateAccessPermissions: jest.fn(), })); jest.mock('./ToolService', () => ({ loadAndFormatTools: jest.fn().mockReturnValue({ diff --git a/api/server/services/Endpoints/anthropic/buildOptions.js b/api/server/services/Endpoints/anthropic/buildOptions.js index ea667be2d..3b02974f3 100644 --- a/api/server/services/Endpoints/anthropic/buildOptions.js +++ b/api/server/services/Endpoints/anthropic/buildOptions.js @@ -6,6 +6,7 @@ const buildOptions = (endpoint, parsedBody) => { promptPrefix, maxContextTokens, resendFiles = true, + promptCache = true, iconURL, greeting, spec, @@ -17,6 +18,7 @@ const buildOptions = (endpoint, parsedBody) => { modelLabel, promptPrefix, resendFiles, + promptCache, iconURL, greeting, spec, diff --git a/api/server/services/start/interface.js b/api/server/services/start/interface.js index aaa4db84b..314babbcf 100644 --- a/api/server/services/start/interface.js +++ b/api/server/services/start/interface.js @@ -1,5 +1,10 @@ -const { SystemRoles, Permissions, removeNullishValues } = require('librechat-data-provider'); -const { updatePromptsAccess, updateBookmarksAccess } = require('~/models/Role'); +const { + SystemRoles, + Permissions, + PermissionTypes, + removeNullishValues, +} = require('librechat-data-provider'); +const { updateAccessPermissions } = require('~/models/Role'); const { logger } = require('~/config'); /** @@ -28,8 +33,10 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol prompts: interfaceConfig?.prompts ?? defaults.prompts, }); - await updatePromptsAccess(roleName, { [Permissions.USE]: loadedInterface.prompts }); - await updateBookmarksAccess(roleName, { [Permissions.USE]: loadedInterface.bookmarks }); + await updateAccessPermissions(roleName, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks }, + }); let i = 0; const logSettings = () => { diff --git a/api/server/services/start/interface.spec.js b/api/server/services/start/interface.spec.js index 8a07ef572..2009e043c 100644 --- a/api/server/services/start/interface.spec.js +++ b/api/server/services/start/interface.spec.js @@ -1,52 +1,81 @@ -const { SystemRoles, Permissions } = require('librechat-data-provider'); -const { updatePromptsAccess } = require('~/models/Role'); +const { SystemRoles, Permissions, PermissionTypes } = require('librechat-data-provider'); +const { updateAccessPermissions } = require('~/models/Role'); const { loadDefaultInterface } = require('./interface'); jest.mock('~/models/Role', () => ({ - updatePromptsAccess: jest.fn(), - updateBookmarksAccess: jest.fn(), + updateAccessPermissions: jest.fn(), })); describe('loadDefaultInterface', () => { - it('should call updatePromptsAccess with the correct parameters when prompts is true', async () => { - const config = { interface: { prompts: true } }; + it('should call updateAccessPermissions with the correct parameters when prompts and bookmarks are true', async () => { + const config = { interface: { prompts: true, bookmarks: true } }; const configDefaults = { interface: {} }; await loadDefaultInterface(config, configDefaults); - expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, { [Permissions.USE]: true }); - }); - - it('should call updatePromptsAccess with false when prompts is false', async () => { - const config = { interface: { prompts: false } }; - const configDefaults = { interface: {} }; - - await loadDefaultInterface(config, configDefaults); - - expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, { - [Permissions.USE]: false, + expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, }); }); - it('should call updatePromptsAccess with undefined when prompts is not specified in config', async () => { + it('should call updateAccessPermissions with false when prompts and bookmarks are false', async () => { + const config = { interface: { prompts: false, bookmarks: false } }; + const configDefaults = { interface: {} }; + + await loadDefaultInterface(config, configDefaults); + + expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + }); + }); + + it('should call updateAccessPermissions with undefined when prompts and bookmarks are not specified in config', async () => { const config = {}; const configDefaults = { interface: {} }; await loadDefaultInterface(config, configDefaults); - expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, { - [Permissions.USE]: undefined, + expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, }); }); - it('should call updatePromptsAccess with undefined when prompts is explicitly undefined', async () => { - const config = { interface: { prompts: undefined } }; + it('should call updateAccessPermissions with undefined when prompts and bookmarks are explicitly undefined', async () => { + const config = { interface: { prompts: undefined, bookmarks: undefined } }; const configDefaults = { interface: {} }; await loadDefaultInterface(config, configDefaults); - expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, { - [Permissions.USE]: undefined, + expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + }); + }); + + it('should call updateAccessPermissions with mixed values for prompts and bookmarks', async () => { + const config = { interface: { prompts: true, bookmarks: false } }; + const configDefaults = { interface: {} }; + + await loadDefaultInterface(config, configDefaults); + + expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + }); + }); + + it('should call updateAccessPermissions with true when config is undefined', async () => { + const config = undefined; + const configDefaults = { interface: { prompts: true, bookmarks: true } }; + + await loadDefaultInterface(config, configDefaults); + + expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { + [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, + [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, }); }); }); diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js index 65e7bbc58..a8001c7e0 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -1,9 +1,9 @@ const express = require('express'); -const oneWeekInSeconds = 24 * 60 * 60 * 7; +const oneDayInSeconds = 24 * 60 * 60; -const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneWeekInSeconds; -const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneWeekInSeconds * 4; +const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds; +const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2; const staticCache = (staticPath) => express.static(staticPath, { diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 90c6d7e07..b89281d27 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -17,6 +17,7 @@ import { useAutoSave, useRequiresKey, useHandleKeyUp, + useQueryParams, useSubmitMessage, } from '~/hooks'; import { TextareaAutosize } from '~/components/ui'; @@ -37,6 +38,7 @@ import store from '~/store'; const ChatForm = ({ index = 0 }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); + useQueryParams({ textAreaRef }); const SpeechToText = useRecoilValue(store.speechToText); const TextToSpeech = useRecoilValue(store.textToSpeech); @@ -61,7 +63,7 @@ const ChatForm = ({ index = 0 }) => { const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({ textAreaRef, submitButtonRef, - disabled: !!requiresKey, + disabled: !!(requiresKey ?? false), }); const { @@ -105,12 +107,12 @@ const ChatForm = ({ index = 0 }) => { const invalidAssistant = useMemo( () => isAssistantsEndpoint(conversation?.endpoint) && - (!conversation?.assistant_id || - !assistantMap[conversation.endpoint ?? ''][conversation.assistant_id ?? '']), + (!(conversation?.assistant_id ?? '') || + !assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']), [conversation?.assistant_id, conversation?.endpoint, assistantMap], ); const disableInputs = useMemo( - () => !!(requiresKey || invalidAssistant), + () => !!((requiresKey ?? false) || invalidAssistant), [requiresKey, invalidAssistant], ); @@ -162,6 +164,8 @@ const ChatForm = ({ index = 0 }) => { {endpoint && ( { ref(e); diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index e0f6b70d0..180b6ca34 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -25,7 +25,7 @@ export const ErrorMessage = ({
-

+

diff --git a/client/src/components/Endpoints/Settings/Anthropic.tsx b/client/src/components/Endpoints/Settings/Anthropic.tsx index 2d45ca93d..6873efe69 100644 --- a/client/src/components/Endpoints/Settings/Anthropic.tsx +++ b/client/src/components/Endpoints/Settings/Anthropic.tsx @@ -29,6 +29,7 @@ export default function Settings({ conversation, setOption, models, readonly }: maxOutputTokens, maxContextTokens, resendFiles, + promptCache, } = conversation ?? {}; const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput( { @@ -47,6 +48,7 @@ export default function Settings({ conversation, setOption, models, readonly }: const setTopP = setOption('topP'); const setTopK = setOption('topK'); const setResendFiles = setOption('resendFiles'); + const setPromptCache = setOption('promptCache'); const setModel = (newModel: string) => { const modelSetter = setOption('model'); @@ -188,7 +190,7 @@ export default function Settings({ conversation, setOption, models, readonly }: className="flex h-4 w-full" /> - + @@ -228,7 +230,7 @@ export default function Settings({ conversation, setOption, models, readonly }: className="flex h-4 w-full" /> - + @@ -269,7 +271,7 @@ export default function Settings({ conversation, setOption, models, readonly }: className="flex h-4 w-full" /> - + @@ -310,7 +312,7 @@ export default function Settings({ conversation, setOption, models, readonly }: /> @@ -329,13 +331,34 @@ export default function Settings({ conversation, setOption, models, readonly }: className="flex" />
+ + +
+ + setPromptCache(checked)} + disabled={readonly} + className="flex" + /> + +
+
+
); diff --git a/client/src/components/Endpoints/Settings/OptionHover.tsx b/client/src/components/Endpoints/Settings/OptionHover.tsx index bcff28940..682ade533 100644 --- a/client/src/components/Endpoints/Settings/OptionHover.tsx +++ b/client/src/components/Endpoints/Settings/OptionHover.tsx @@ -26,6 +26,7 @@ const types = { topk: 'com_endpoint_anthropic_topk', maxoutputtokens: 'com_endpoint_anthropic_maxoutputtokens', resend: openAI.resend, + promptcache: 'com_endpoint_anthropic_prompt_cache', }, google: { temp: 'com_endpoint_google_temp', @@ -44,7 +45,7 @@ const types = { function OptionHover({ endpoint, type, side }: TOptionHoverProps) { const localize = useLocalize(); - const text = types?.[endpoint]?.[type]; + const text = types[endpoint]?.[type]; if (!text) { return null; } diff --git a/client/src/components/Messages/Content/CodeBlock.tsx b/client/src/components/Messages/Content/CodeBlock.tsx index 1c052873f..7f1b4e1b3 100644 --- a/client/src/components/Messages/Content/CodeBlock.tsx +++ b/client/src/components/Messages/Content/CodeBlock.tsx @@ -37,7 +37,7 @@ const CodeBar: React.FC = React.memo(({ lang, codeRef, error, plug const codeString = codeRef.current?.textContent; if (codeString != null) { setIsCopied(true); - copy(codeString, { format: 'text/plain' }); + copy(codeString.trim(), { format: 'text/plain' }); setTimeout(() => { setIsCopied(false); @@ -70,16 +70,17 @@ const CodeBlock: React.FC = ({ error, }) => { const codeRef = useRef(null); - const language = plugin || error ? 'json' : lang; + const isNonCode = !!(plugin === true || error === true); + const language = isNonCode ? 'json' : lang; return (
- +
{codeChildren} diff --git a/client/src/components/Prompts/Groups/DashGroupItem.tsx b/client/src/components/Prompts/Groups/DashGroupItem.tsx index da987adc0..c1e1d2921 100644 --- a/client/src/components/Prompts/Groups/DashGroupItem.tsx +++ b/client/src/components/Prompts/Groups/DashGroupItem.tsx @@ -61,7 +61,7 @@ export default function DashGroupItem({ }; const saveRename = () => { - updateGroup.mutate({ payload: { name: nameInputField }, id: group._id || '' }); + updateGroup.mutate({ payload: { name: nameInputField }, id: group._id ?? '' }); }; const handleBlur = () => { @@ -77,13 +77,13 @@ export default function DashGroupItem({ } }; - const handleRename = (e: React.MouseEvent | React.KeyboardEvent) => { + const handleRename = (e: Event) => { e.stopPropagation(); setNameEditFlag(true); }; const handleDelete = () => { - deletePromptGroupMutation.mutate({ id: group._id || '' }); + deletePromptGroupMutation.mutate({ id: group._id ?? '' }); }; return ( @@ -156,7 +156,7 @@ export default function DashGroupItem({
- {groupIsGlobal && ( + {groupIsGlobal === true && (
- {group.oneliner ? group.oneliner : group.productionPrompt?.prompt ?? ''} + {group.oneliner ?? '' ? group.oneliner : group.productionPrompt?.prompt ?? ''}
)} diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx index bb81c8f4c..f8d0d82fd 100644 --- a/client/src/components/Prompts/Groups/List.tsx +++ b/client/src/components/Prompts/Groups/List.tsx @@ -39,7 +39,7 @@ export default function List({
)}
-
+
{isLoading && isChatRoute && ( )} diff --git a/client/src/components/Prompts/Groups/ListCard.tsx b/client/src/components/Prompts/Groups/ListCard.tsx index b438212ad..2730bdc27 100644 --- a/client/src/components/Prompts/Groups/ListCard.tsx +++ b/client/src/components/Prompts/Groups/ListCard.tsx @@ -28,7 +28,9 @@ export default function ListCard({
{children}
-
{snippet}
+
+ {snippet} +
); } diff --git a/client/src/hooks/Input/index.ts b/client/src/hooks/Input/index.ts index beebe0f91..bfc40ebb3 100644 --- a/client/src/hooks/Input/index.ts +++ b/client/src/hooks/Input/index.ts @@ -3,6 +3,7 @@ export { default as useUserKey } from './useUserKey'; export { default as useDebounce } from './useDebounce'; export { default as useTextarea } from './useTextarea'; export { default as useCombobox } from './useCombobox'; +export { default as useQueryParams } from './useQueryParams'; export { default as useHandleKeyUp } from './useHandleKeyUp'; export { default as useRequiresKey } from './useRequiresKey'; export { default as useMultipleKeys } from './useMultipleKeys'; diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts new file mode 100644 index 000000000..1d70eb75d --- /dev/null +++ b/client/src/hooks/Input/useQueryParams.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useChatFormContext } from '~/Providers'; + +export default function useQueryParams({ + textAreaRef, +}: { + textAreaRef: React.RefObject; +}) { + const methods = useChatFormContext(); + const [searchParams] = useSearchParams(); + const attemptsRef = useRef(0); + const processedRef = useRef(false); + const maxAttempts = 50; // 5 seconds maximum (50 * 100ms) + + useEffect(() => { + const promptParam = searchParams.get('prompt'); + if (!promptParam) { + return; + } + + const decodedPrompt = decodeURIComponent(promptParam); + + const intervalId = setInterval(() => { + // If already processed or max attempts reached, clear interval and stop + if (processedRef.current || attemptsRef.current >= maxAttempts) { + clearInterval(intervalId); + if (attemptsRef.current >= maxAttempts) { + console.warn('Max attempts reached, failed to process prompt'); + } + return; + } + + attemptsRef.current += 1; + + if (textAreaRef.current) { + const currentText = methods.getValues('text'); + + // Only update if the textarea is empty + if (!currentText) { + methods.setValue('text', decodedPrompt, { shouldValidate: true }); + textAreaRef.current.focus(); + textAreaRef.current.setSelectionRange(decodedPrompt.length, decodedPrompt.length); + + // Remove the 'prompt' parameter from the URL + searchParams.delete('prompt'); + const newUrl = `${window.location.pathname}${ + searchParams.toString() ? `?${searchParams.toString()}` : '' + }`; + window.history.replaceState({}, '', newUrl); + + processedRef.current = true; + console.log('Prompt processed successfully'); + } + + clearInterval(intervalId); + } + }, 100); // Check every 100ms + + // Clean up the interval on unmount + return () => { + clearInterval(intervalId); + console.log('Cleanup: interval cleared'); + }; + }, [searchParams, methods, textAreaRef]); +} diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index d4679917d..d65a70946 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -86,6 +86,7 @@ export default function useEventHandlers({ isRegenerate = false, } = submission; const text = data ?? ''; + setIsSubmitting(true); if (text.length > 0) { announcePolite({ message: text, @@ -118,7 +119,7 @@ export default function useEventHandlers({ ]); } }, - [setMessages, announcePolite], + [setMessages, announcePolite, setIsSubmitting], ); const cancelHandler = useCallback( @@ -387,6 +388,10 @@ export default function useEventHandlers({ } if (setConversation && isAddedRequest !== true) { + if (window.location.pathname === '/c/new') { + window.history.pushState({}, '', '/c/' + conversation.conversationId); + } + setConversation((prevState) => { const update = { ...prevState, diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index ae5adb818..d998e5144 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -472,6 +472,9 @@ export default { 'Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model\'s vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).', com_endpoint_anthropic_maxoutputtokens: 'Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. Note: models may stop before reaching this maximum.', + com_endpoint_anthropic_prompt_cache: + 'Prompt caching allows reusing large context or instructions across API calls, reducing costs and latency', + com_endpoint_prompt_cache: 'Use Prompt Caching', com_endpoint_anthropic_custom_name_placeholder: 'Set a custom name for Anthropic', com_endpoint_frequency_penalty: 'Frequency Penalty', com_endpoint_presence_penalty: 'Presence Penalty', diff --git a/client/src/style.css b/client/src/style.css index 548682cd7..8f415570b 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1776,9 +1776,7 @@ button.scroll-convo { } .result-streaming > :not(ol):not(ul):not(pre):last-child:after, -.result-streaming > pre:last-child code:after, -.result-streaming > ol:last-child > li:last-child:after, -.result-streaming > ul:last-child > li:last-child:after { +.result-streaming > pre:last-child code:after { display: inline-block; content: '⬤'; width: 12px; @@ -1792,9 +1790,7 @@ button.scroll-convo { } @supports (selector(:has(*))) { - .result-streaming > :not(ol):not(ul):last-child:after, - .result-streaming > ol:last-child > li:last-child:not(:has(ol)):not(:has(ul)):after, - .result-streaming > ul:last-child > li:last-child:not(:has(ol)):not(:has(ul)):after { + .result-streaming > :is(ul, ol):last-child > li:last-child:not(:has(> :is(ul, ol, pre))):after { content: '⬤'; font-family: system-ui, Inter, Söhne Circle, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; @@ -1807,8 +1803,8 @@ button.scroll-convo { height: 12px; } } + @supports not (selector(:has(*))) { - .result-streaming > :not(ol):not(ul):last-child:after, .result-streaming > ol:last-child > li:last-child:after, .result-streaming > ul:last-child > li:last-child:after { content: '⬤'; @@ -1822,6 +1818,21 @@ button.scroll-convo { width: 12px; height: 12px; } + + .result-streaming > ol:last-child > li:last-child > :is(ul, ol, pre) ~ :after, + .result-streaming > ul:last-child > li:last-child > :is(ul, ol, pre) ~ :after { + display: none; + } + + .result-streaming > ol:last-child > li:last-child > pre:last-child code:after, + .result-streaming > ul:last-child > li:last-child > pre:last-child code:after { + display: inline-block; + } +} + +/* Remove cursors when streaming is complete */ +.result-streaming:not(.submitting) :is(ul, ol) li:after { + display: none !important; } .webkit-dark-styles, @@ -1907,8 +1918,10 @@ button.scroll-convo { } /* Base styles for lists */ -.prose ol, .prose ul, -.markdown ol, .markdown ul { +.prose ol, +.prose ul, +.markdown ol, +.markdown ul { list-style-position: outside; margin-top: 1em; margin-bottom: 1em; @@ -1979,8 +1992,14 @@ button.scroll-convo { } /* Nested lists */ -.prose ol ol, .prose ul ul, .prose ul ol, .prose ol ul, -.markdown ol ol, .markdown ul ul, .markdown ul ol, .markdown ol ul { +.prose ol ol, +.prose ul ul, +.prose ul ol, +.prose ol ul, +.markdown ol ol, +.markdown ul ul, +.markdown ul ol, +.markdown ol ul { margin-top: 0.75em; margin-bottom: 0.75em; } diff --git a/package-lock.json b/package-lock.json index 64292fd35..9406ce2ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31702,7 +31702,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.416", + "version": "0.7.417", "license": "ISC", "dependencies": { "@types/js-yaml": "^4.0.9", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 688dfde48..c263ba2bb 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.416", + "version": "0.7.417", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 37c761a16..bfdefd26b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -423,6 +423,8 @@ export const configSchema = z.object({ parameters: true, sidePanel: true, presets: true, + bookmarks: true, + prompts: true, }), fileStrategy: fileSourceSchema.default(FileSources.local), registration: z diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index b1c4ccee9..ba5b2be42 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -171,6 +171,9 @@ export const anthropicSettings = { step: 0.01, default: 1, }, + promptCache: { + default: true, + }, maxOutputTokens: { min: 1, max: ANTHROPIC_MAX_OUTPUT, @@ -393,6 +396,8 @@ export const tConversationSchema = z.object({ file_ids: z.array(z.string()).optional(), maxContextTokens: coerceNumber.optional(), max_tokens: coerceNumber.optional(), + /* Anthropic */ + promptCache: z.boolean().optional(), /* vision */ resendFiles: z.boolean().optional(), imageDetail: eImageDetailSchema.optional(), @@ -648,6 +653,7 @@ export const anthropicSchema = tConversationSchema topP: true, topK: true, resendFiles: true, + promptCache: true, iconURL: true, greeting: true, spec: true, @@ -664,6 +670,10 @@ export const anthropicSchema = tConversationSchema maxOutputTokens: obj.maxOutputTokens ?? anthropicSettings.maxOutputTokens.reset(model), topP: obj.topP ?? anthropicSettings.topP.default, topK: obj.topK ?? anthropicSettings.topK.default, + promptCache: + typeof obj.promptCache === 'boolean' + ? obj.promptCache + : anthropicSettings.promptCache.default, resendFiles: typeof obj.resendFiles === 'boolean' ? obj.resendFiles @@ -683,6 +693,7 @@ export const anthropicSchema = tConversationSchema topP: anthropicSettings.topP.default, topK: anthropicSettings.topK.default, resendFiles: anthropicSettings.resendFiles.default, + promptCache: anthropicSettings.promptCache.default, iconURL: undefined, greeting: undefined, spec: undefined, @@ -911,6 +922,7 @@ export const compactAnthropicSchema = tConversationSchema topP: true, topK: true, resendFiles: true, + promptCache: true, iconURL: true, greeting: true, spec: true, @@ -933,6 +945,9 @@ export const compactAnthropicSchema = tConversationSchema if (newObj.resendFiles === anthropicSettings.resendFiles.default) { delete newObj.resendFiles; } + if (newObj.promptCache === anthropicSettings.promptCache.default) { + delete newObj.promptCache; + } return removeNullishValues(newObj); }) diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index bebc0500d..1bbdbeaf6 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -26,6 +26,7 @@ export type TEndpointOption = { endpointType?: EModelEndpoint; modelDisplayLabel?: string; resendFiles?: boolean; + promptCache?: boolean; maxContextTokens?: number; imageDetail?: ImageDetail; model?: string | null;