diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 09444a1b44..207aa17e66 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -30,8 +30,8 @@ Project maintainers have the right and responsibility to remove, edit, or reject 2. Install typescript globally: `npm i -g typescript`. 3. Run `npm ci` to install dependencies. 4. Build the data provider: `npm run build:data-provider`. -5. Build MCP: `npm run build:mcp`. -6. Build data schemas: `npm run build:data-schemas`. +5. Build data schemas: `npm run build:data-schemas`. +6. Build API methods: `npm run build:api`. 7. Setup and run unit tests: - Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`. - Run backend unit tests: `npm run test:api`. diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index b7bccecae8..0c593eac18 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -7,6 +7,7 @@ on: - release/* paths: - 'api/**' + - 'packages/api/**' jobs: tests_Backend: name: Run Backend unit tests @@ -36,12 +37,12 @@ jobs: - name: Install Data Provider Package run: npm run build:data-provider - - name: Install MCP Package - run: npm run build:mcp - - name: Install Data Schemas Package run: npm run build:data-schemas + - name: Install API Package + run: npm run build:api + - name: Create empty auth.json file run: | mkdir -p api/data @@ -66,5 +67,5 @@ jobs: - name: Run librechat-data-provider unit tests run: cd packages/data-provider && npm run test:ci - - name: Run librechat-mcp unit tests - run: cd packages/mcp && npm run test:ci \ No newline at end of file + - name: Run librechat-api unit tests + run: cd packages/api && npm run test:ci \ No newline at end of file diff --git a/Dockerfile.multi b/Dockerfile.multi index 991f805bec..26c5705376 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -14,7 +14,7 @@ RUN npm config set fetch-retry-maxtimeout 600000 && \ npm config set fetch-retry-mintimeout 15000 COPY package*.json ./ COPY packages/data-provider/package*.json ./packages/data-provider/ -COPY packages/mcp/package*.json ./packages/mcp/ +COPY packages/api/package*.json ./packages/api/ COPY packages/data-schemas/package*.json ./packages/data-schemas/ COPY client/package*.json ./client/ COPY api/package*.json ./api/ @@ -24,26 +24,27 @@ FROM base-min AS base WORKDIR /app RUN npm ci -# Build data-provider +# Build `data-provider` package FROM base AS data-provider-build WORKDIR /app/packages/data-provider COPY packages/data-provider ./ RUN npm run build -# Build mcp package -FROM base AS mcp-build -WORKDIR /app/packages/mcp -COPY packages/mcp ./ -COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist -RUN npm run build - -# Build data-schemas +# Build `data-schemas` package FROM base AS data-schemas-build WORKDIR /app/packages/data-schemas COPY packages/data-schemas ./ COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist RUN npm run build +# Build `api` package +FROM base AS api-build +WORKDIR /app/packages/api +COPY packages/api ./ +COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist +RUN npm run build + # Client build FROM base AS client-build WORKDIR /app/client @@ -63,8 +64,8 @@ RUN npm ci --omit=dev COPY api ./api COPY config ./config COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist -COPY --from=mcp-build /app/packages/mcp/dist ./packages/mcp/dist COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist +COPY --from=api-build /app/packages/api/dist ./packages/api/dist COPY --from=client-build /app/client/dist ./client/dist WORKDIR /app/api EXPOSE 3080 diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 0da331ced5..037f1e7c46 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -10,6 +10,7 @@ const { validateVisionModel, } = require('librechat-data-provider'); const { SplitStreamHandler: _Handler } = require('@librechat/agents'); +const { Tokenizer, createFetch, createStreamEventHandlers } = require('@librechat/api'); const { truncateText, formatMessage, @@ -26,8 +27,6 @@ const { const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { createFetch, createStreamEventHandlers } = require('./generators'); -const Tokenizer = require('~/server/services/Tokenizer'); const { sleep } = require('~/server/utils'); const BaseClient = require('./BaseClient'); const { logger } = require('~/config'); diff --git a/api/app/clients/ChatGPTClient.js b/api/app/clients/ChatGPTClient.js index 36a3f4936a..555028dc3f 100644 --- a/api/app/clients/ChatGPTClient.js +++ b/api/app/clients/ChatGPTClient.js @@ -2,6 +2,7 @@ const { Keyv } = require('keyv'); const crypto = require('crypto'); const { CohereClient } = require('cohere-ai'); const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source'); +const { constructAzureURL, genAzureChatCompletion } = require('@librechat/api'); const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken'); const { ImageDetail, @@ -10,9 +11,9 @@ const { CohereConstants, mapModelToAzureConfig, } = require('librechat-data-provider'); -const { extractBaseURL, constructAzureURL, genAzureChatCompletion } = require('~/utils'); const { createContextHandlers } = require('./prompts'); const { createCoherePayload } = require('./llm'); +const { extractBaseURL } = require('~/utils'); const BaseClient = require('./BaseClient'); const { logger } = require('~/config'); diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 4151e6663a..2ded0657c7 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -1,4 +1,5 @@ const { google } = require('googleapis'); +const { Tokenizer } = require('@librechat/api'); const { concat } = require('@langchain/core/utils/stream'); const { ChatVertexAI } = require('@langchain/google-vertexai'); const { ChatGoogleGenerativeAI } = require('@langchain/google-genai'); @@ -19,7 +20,6 @@ const { } = require('librechat-data-provider'); const { getSafetySettings } = require('~/server/services/Endpoints/google/llm'); const { encodeAndFormat } = require('~/server/services/Files/images'); -const Tokenizer = require('~/server/services/Tokenizer'); const { spendTokens } = require('~/models/spendTokens'); const { getModelMaxTokens } = require('~/utils'); const { sleep } = require('~/server/utils'); diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 280db89284..f3a7e67c12 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,14 @@ const { OllamaClient } = require('./OllamaClient'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); +const { + isEnabled, + Tokenizer, + createFetch, + constructAzureURL, + genAzureChatCompletion, + createStreamEventHandlers, +} = require('@librechat/api'); const { Constants, ImageDetail, @@ -16,13 +24,6 @@ const { validateVisionModel, mapModelToAzureConfig, } = require('librechat-data-provider'); -const { - extractBaseURL, - constructAzureURL, - getModelMaxTokens, - genAzureChatCompletion, - getModelMaxOutputTokens, -} = require('~/utils'); const { truncateText, formatMessage, @@ -30,10 +31,9 @@ const { titleInstruction, createContextHandlers, } = require('./prompts'); +const { extractBaseURL, getModelMaxTokens, getModelMaxOutputTokens } = require('~/utils'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { createFetch, createStreamEventHandlers } = require('./generators'); -const { addSpaceIfNeeded, isEnabled, sleep } = require('~/server/utils'); -const Tokenizer = require('~/server/services/Tokenizer'); +const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { spendTokens } = require('~/models/spendTokens'); const { handleOpenAIErrors } = require('./tools/util'); const { createLLM, RunManager } = require('./llm'); diff --git a/api/app/clients/generators.js b/api/app/clients/generators.js deleted file mode 100644 index 9814cac7a5..0000000000 --- a/api/app/clients/generators.js +++ /dev/null @@ -1,71 +0,0 @@ -const fetch = require('node-fetch'); -const { GraphEvents } = require('@librechat/agents'); -const { logger, sendEvent } = require('~/config'); -const { sleep } = require('~/server/utils'); - -/** - * Makes a function to make HTTP request and logs the process. - * @param {Object} params - * @param {boolean} [params.directEndpoint] - Whether to use a direct endpoint. - * @param {string} [params.reverseProxyUrl] - The reverse proxy URL to use for the request. - * @returns {Promise} - A promise that resolves to the response of the fetch request. - */ -function createFetch({ directEndpoint = false, reverseProxyUrl = '' }) { - /** - * Makes an HTTP request and logs the process. - * @param {RequestInfo} url - The URL to make the request to. Can be a string or a Request object. - * @param {RequestInit} [init] - Optional init options for the request. - * @returns {Promise} - A promise that resolves to the response of the fetch request. - */ - return async (_url, init) => { - let url = _url; - if (directEndpoint) { - url = reverseProxyUrl; - } - logger.debug(`Making request to ${url}`); - if (typeof Bun !== 'undefined') { - return await fetch(url, init); - } - return await fetch(url, init); - }; -} - -// Add this at the module level outside the class -/** - * Creates event handlers for stream events that don't capture client references - * @param {Object} res - The response object to send events to - * @returns {Object} Object containing handler functions - */ -function createStreamEventHandlers(res) { - return { - [GraphEvents.ON_RUN_STEP]: (event) => { - if (res) { - sendEvent(res, event); - } - }, - [GraphEvents.ON_MESSAGE_DELTA]: (event) => { - if (res) { - sendEvent(res, event); - } - }, - [GraphEvents.ON_REASONING_DELTA]: (event) => { - if (res) { - sendEvent(res, event); - } - }, - }; -} - -function createHandleLLMNewToken(streamRate) { - return async () => { - if (streamRate) { - await sleep(streamRate); - } - }; -} - -module.exports = { - createFetch, - createHandleLLMNewToken, - createStreamEventHandlers, -}; diff --git a/api/app/clients/llm/createLLM.js b/api/app/clients/llm/createLLM.js index c8d6666bce..846c4d8e9c 100644 --- a/api/app/clients/llm/createLLM.js +++ b/api/app/clients/llm/createLLM.js @@ -1,6 +1,5 @@ const { ChatOpenAI } = require('@langchain/openai'); -const { sanitizeModelName, constructAzureURL } = require('~/utils'); -const { isEnabled } = require('~/server/utils'); +const { isEnabled, sanitizeModelName, constructAzureURL } = require('@librechat/api'); /** * Creates a new instance of a language model (LLM) for chat interactions. diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 0ba77db6fa..6d44915804 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -33,7 +33,9 @@ jest.mock('~/models', () => ({ const { getConvo, saveConvo } = require('~/models'); jest.mock('@librechat/agents', () => { + const { Providers } = jest.requireActual('@librechat/agents'); return { + Providers, ChatOpenAI: jest.fn().mockImplementation(() => { return {}; }), diff --git a/api/app/clients/tools/dynamic/OpenAPIPlugin.js b/api/app/clients/tools/dynamic/OpenAPIPlugin.js deleted file mode 100644 index acc3a64d32..0000000000 --- a/api/app/clients/tools/dynamic/OpenAPIPlugin.js +++ /dev/null @@ -1,184 +0,0 @@ -require('dotenv').config(); -const fs = require('fs'); -const { z } = require('zod'); -const path = require('path'); -const yaml = require('js-yaml'); -const { createOpenAPIChain } = require('langchain/chains'); -const { DynamicStructuredTool } = require('@langchain/core/tools'); -const { ChatPromptTemplate, HumanMessagePromptTemplate } = require('@langchain/core/prompts'); -const { logger } = require('~/config'); - -function addLinePrefix(text, prefix = '// ') { - return text - .split('\n') - .map((line) => prefix + line) - .join('\n'); -} - -function createPrompt(name, functions) { - const prefix = `// The ${name} tool has the following functions. Determine the desired or most optimal function for the user's query:`; - const functionDescriptions = functions - .map((func) => `// - ${func.name}: ${func.description}`) - .join('\n'); - return `${prefix}\n${functionDescriptions} -// You are an expert manager and scrum master. You must provide a detailed intent to better execute the function. -// Always format as such: {{"func": "function_name", "intent": "intent and expected result"}}`; -} - -const AuthBearer = z - .object({ - type: z.string().includes('service_http'), - authorization_type: z.string().includes('bearer'), - verification_tokens: z.object({ - openai: z.string(), - }), - }) - .catch(() => false); - -const AuthDefinition = z - .object({ - type: z.string(), - authorization_type: z.string(), - verification_tokens: z.object({ - openai: z.string(), - }), - }) - .catch(() => false); - -async function readSpecFile(filePath) { - try { - const fileContents = await fs.promises.readFile(filePath, 'utf8'); - if (path.extname(filePath) === '.json') { - return JSON.parse(fileContents); - } - return yaml.load(fileContents); - } catch (e) { - logger.error('[readSpecFile] error', e); - return false; - } -} - -async function getSpec(url) { - const RegularUrl = z - .string() - .url() - .catch(() => false); - - if (RegularUrl.parse(url) && path.extname(url) === '.json') { - const response = await fetch(url); - return await response.json(); - } - - const ValidSpecPath = z - .string() - .url() - .catch(async () => { - const spec = path.join(__dirname, '..', '.well-known', 'openapi', url); - if (!fs.existsSync(spec)) { - return false; - } - - return await readSpecFile(spec); - }); - - return ValidSpecPath.parse(url); -} - -async function createOpenAPIPlugin({ data, llm, user, message, memory, signal }) { - let spec; - try { - spec = await getSpec(data.api.url); - } catch (error) { - logger.error('[createOpenAPIPlugin] getSpec error', error); - return null; - } - - if (!spec) { - logger.warn('[createOpenAPIPlugin] No spec found'); - return null; - } - - const headers = {}; - const { auth, name_for_model, description_for_model, description_for_human } = data; - if (auth && AuthDefinition.parse(auth)) { - logger.debug('[createOpenAPIPlugin] auth detected', auth); - const { openai } = auth.verification_tokens; - if (AuthBearer.parse(auth)) { - headers.authorization = `Bearer ${openai}`; - logger.debug('[createOpenAPIPlugin] added auth bearer', headers); - } - } - - const chainOptions = { llm }; - - if (data.headers && data.headers['librechat_user_id']) { - logger.debug('[createOpenAPIPlugin] id detected', headers); - headers[data.headers['librechat_user_id']] = user; - } - - if (Object.keys(headers).length > 0) { - logger.debug('[createOpenAPIPlugin] headers detected', headers); - chainOptions.headers = headers; - } - - if (data.params) { - logger.debug('[createOpenAPIPlugin] params detected', data.params); - chainOptions.params = data.params; - } - - let history = ''; - if (memory) { - logger.debug('[createOpenAPIPlugin] openAPI chain: memory detected', memory); - const { history: chat_history } = await memory.loadMemoryVariables({}); - history = chat_history?.length > 0 ? `\n\n## Chat History:\n${chat_history}\n` : ''; - } - - chainOptions.prompt = ChatPromptTemplate.fromMessages([ - HumanMessagePromptTemplate.fromTemplate( - `# Use the provided API's to respond to this query:\n\n{query}\n\n## Instructions:\n${addLinePrefix( - description_for_model, - )}${history}`, - ), - ]); - - const chain = await createOpenAPIChain(spec, chainOptions); - - const { functions } = chain.chains[0].lc_kwargs.llmKwargs; - - return new DynamicStructuredTool({ - name: name_for_model, - description_for_model: `${addLinePrefix(description_for_human)}${createPrompt( - name_for_model, - functions, - )}`, - description: `${description_for_human}`, - schema: z.object({ - func: z - .string() - .describe( - `The function to invoke. The functions available are: ${functions - .map((func) => func.name) - .join(', ')}`, - ), - intent: z - .string() - .describe('Describe your intent with the function and your expected result'), - }), - func: async ({ func = '', intent = '' }) => { - const filteredFunctions = functions.filter((f) => f.name === func); - chain.chains[0].lc_kwargs.llmKwargs.functions = filteredFunctions; - const query = `${message}${func?.length > 0 ? `\n// Intent: ${intent}` : ''}`; - const result = await chain.call({ - query, - signal, - }); - return result.response; - }, - }); -} - -module.exports = { - getSpec, - readSpecFile, - createOpenAPIPlugin, -}; diff --git a/api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js b/api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js deleted file mode 100644 index 83bc5e9397..0000000000 --- a/api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -const fs = require('fs'); -const { createOpenAPIPlugin, getSpec, readSpecFile } = require('./OpenAPIPlugin'); - -global.fetch = jest.fn().mockImplementationOnce(() => { - return new Promise((resolve) => { - resolve({ - ok: true, - json: () => Promise.resolve({ key: 'value' }), - }); - }); -}); -jest.mock('fs', () => ({ - promises: { - readFile: jest.fn(), - }, - existsSync: jest.fn(), -})); - -describe('readSpecFile', () => { - it('reads JSON file correctly', async () => { - fs.promises.readFile.mockResolvedValue(JSON.stringify({ test: 'value' })); - const result = await readSpecFile('test.json'); - expect(result).toEqual({ test: 'value' }); - }); - - it('reads YAML file correctly', async () => { - fs.promises.readFile.mockResolvedValue('test: value'); - const result = await readSpecFile('test.yaml'); - expect(result).toEqual({ test: 'value' }); - }); - - it('handles error correctly', async () => { - fs.promises.readFile.mockRejectedValue(new Error('test error')); - const result = await readSpecFile('test.json'); - expect(result).toBe(false); - }); -}); - -describe('getSpec', () => { - it('fetches spec from url correctly', async () => { - const parsedJson = await getSpec('https://www.instacart.com/.well-known/ai-plugin.json'); - const isObject = typeof parsedJson === 'object'; - expect(isObject).toEqual(true); - }); - - it('reads spec from file correctly', async () => { - fs.existsSync.mockReturnValue(true); - fs.promises.readFile.mockResolvedValue(JSON.stringify({ test: 'value' })); - const result = await getSpec('test.json'); - expect(result).toEqual({ test: 'value' }); - }); - - it('returns false when file does not exist', async () => { - fs.existsSync.mockReturnValue(false); - const result = await getSpec('test.json'); - expect(result).toBe(false); - }); -}); - -describe('createOpenAPIPlugin', () => { - it('returns null when getSpec throws an error', async () => { - const result = await createOpenAPIPlugin({ data: { api: { url: 'invalid' } } }); - expect(result).toBe(null); - }); - - it('returns null when no spec is found', async () => { - const result = await createOpenAPIPlugin({}); - expect(result).toBe(null); - }); - - // Add more tests here for different scenarios -}); diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index fc0f1851f6..7c2a56fe71 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -8,10 +8,10 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const { getImageBasename } = require('~/server/services/Files/images'); const extractBaseURL = require('~/utils/extractBaseURL'); -const { logger } = require('~/config'); +const logger = require('~/config/winston'); const displayMessage = - 'DALL-E displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + "DALL-E displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; class DALLE3 extends Tool { constructor(fields = {}) { super(); diff --git a/api/app/clients/tools/structured/specs/DALLE3.spec.js b/api/app/clients/tools/structured/specs/DALLE3.spec.js index 1b28de2faf..2def575fb3 100644 --- a/api/app/clients/tools/structured/specs/DALLE3.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3.spec.js @@ -1,10 +1,29 @@ const OpenAI = require('openai'); const DALLE3 = require('../DALLE3'); - -const { logger } = require('~/config'); +const logger = require('~/config/winston'); jest.mock('openai'); +jest.mock('@librechat/data-schemas', () => { + return { + logger: { + info: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + }; +}); + +jest.mock('tiktoken', () => { + return { + encoding_for_model: jest.fn().mockReturnValue({ + encode: jest.fn(), + decode: jest.fn(), + }), + }; +}); + const processFileURL = jest.fn(); jest.mock('~/server/services/Files/images', () => ({ @@ -37,6 +56,11 @@ jest.mock('fs', () => { return { existsSync: jest.fn(), mkdirSync: jest.fn(), + promises: { + writeFile: jest.fn(), + readFile: jest.fn(), + unlink: jest.fn(), + }, }; }); diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 54da483362..19d3a79edb 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -135,7 +135,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => { query: z .string() .describe( - 'A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you\'re looking for. The query will be used for semantic similarity matching against the file contents.', + "A natural language query to search for relevant information in the files. Be specific and use keywords related to the information you're looking for. The query will be used for semantic similarity matching against the file contents.", ), }), }, diff --git a/api/config/index.js b/api/config/index.js index e238f700be..8f24581be2 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -1,7 +1,7 @@ const axios = require('axios'); const { EventSource } = require('eventsource'); -const { Time, CacheKeys } = require('librechat-data-provider'); -const { MCPManager, FlowStateManager } = require('librechat-mcp'); +const { Time } = require('librechat-data-provider'); +const { MCPManager, FlowStateManager } = require('@librechat/api'); const logger = require('./winston'); global.EventSource = EventSource; diff --git a/api/package.json b/api/package.json index 6646c3a493..22be4f18e9 100644 --- a/api/package.json +++ b/api/package.json @@ -49,6 +49,7 @@ "@langchain/google-vertexai": "^0.2.9", "@langchain/textsplitters": "^0.1.0", "@librechat/agents": "^2.4.38", + "@librechat/api": "*", "@librechat/data-schemas": "*", "@node-saml/passport-saml": "^5.0.0", "@waylaidwanderer/fetch-event-source": "^3.0.1", @@ -81,7 +82,6 @@ "keyv-file": "^5.1.2", "klona": "^2.0.6", "librechat-data-provider": "*", - "librechat-mcp": "*", "lodash": "^4.17.21", "meilisearch": "^0.38.0", "memorystore": "^1.6.7", @@ -90,6 +90,7 @@ "mongoose": "^8.12.1", "multer": "^2.0.0", "nanoid": "^3.3.7", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.15", "ollama": "^0.5.0", "openai": "^4.96.2", @@ -110,7 +111,7 @@ "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", "winston": "^3.11.0", - "winston-daily-rotate-file": "^4.7.1", + "winston-daily-rotate-file": "^5.0.0", "youtube-transcript": "^1.2.1", "zod": "^3.22.4" }, diff --git a/api/server/cleanup.js b/api/server/cleanup.js index 5bf336eed5..de7450cea0 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -220,6 +220,9 @@ function disposeClient(client) { if (client.maxResponseTokens) { client.maxResponseTokens = null; } + if (client.processMemory) { + client.processMemory = null; + } if (client.run) { // Break circular references in run if (client.run.Graph) { diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index cedfc6bd62..60e68b5f2d 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -1,4 +1,6 @@ const { nanoid } = require('nanoid'); +const { sendEvent } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { Tools, StepTypes, FileContext } = require('librechat-data-provider'); const { EnvVar, @@ -12,7 +14,6 @@ const { const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { saveBase64Image } = require('~/server/services/Files/process'); -const { logger, sendEvent } = require('~/config'); class ModelEndHandler { /** @@ -240,9 +241,7 @@ function createToolEndCallback({ req, res, artifactPromises }) { if (output.artifact[Tools.web_search]) { artifactPromises.push( (async () => { - const name = `${output.name}_${output.tool_call_id}_${nanoid()}`; const attachment = { - name, type: Tools.web_search, messageId: metadata.run_id, toolCallId: output.tool_call_id, diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9631fe3801..7c8a375a9f 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1,13 +1,12 @@ -// const { HttpsProxyAgent } = require('https-proxy-agent'); -// const { -// Constants, -// ImageDetail, -// EModelEndpoint, -// resolveHeaders, -// validateVisionModel, -// mapModelToAzureConfig, -// } = require('librechat-data-provider'); require('events').EventEmitter.defaultMaxListeners = 100; +const { logger } = require('@librechat/data-schemas'); +const { + sendEvent, + createRun, + Tokenizer, + memoryInstructions, + createMemoryProcessor, +} = require('@librechat/api'); const { Callback, GraphEvents, @@ -19,26 +18,30 @@ const { } = require('@librechat/agents'); const { Constants, + Permissions, VisionModes, ContentTypes, EModelEndpoint, KnownEndpoints, + PermissionTypes, isAgentsEndpoint, AgentCapabilities, bedrockInputSchema, removeNullishValues, } = require('librechat-data-provider'); +const { DynamicStructuredTool } = require('@langchain/core/tools'); +const { getBufferString, HumanMessage } = require('@langchain/core/messages'); const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config'); const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); +const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); -const { getBufferString, HumanMessage } = require('@langchain/core/messages'); -const { DynamicStructuredTool } = require('@langchain/core/tools'); +const { setMemory, deleteMemory, getFormattedMemories } = require('~/models'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); -const Tokenizer = require('~/server/services/Tokenizer'); +const { checkAccess } = require('~/server/middleware/roles/access'); const BaseClient = require('~/app/clients/BaseClient'); -const { logger, sendEvent, getMCPManager } = require('~/config'); -const { createRun } = require('./run'); +const { loadAgent } = require('~/models/Agent'); +const { getMCPManager } = require('~/config'); /** * @param {ServerRequest} req @@ -58,12 +61,8 @@ const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deep const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi]; -// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory'); -// const { getFormattedMemories } = require('~/models/Memory'); -// const { getCurrentDateTime } = require('~/utils'); - function createTokenCounter(encoding) { - return (message) => { + return function (message) { const countTokens = (text) => Tokenizer.getTokenCount(text, encoding); return getTokenCountForMessage(message, countTokens); }; @@ -124,6 +123,8 @@ class AgentClient extends BaseClient { this.usage; /** @type {Record} */ this.indexTokenCountMap = {}; + /** @type {(messages: BaseMessage[]) => Promise} */ + this.processMemory; } /** @@ -138,55 +139,10 @@ class AgentClient extends BaseClient { } /** - * - * Checks if the model is a vision model based on request attachments and sets the appropriate options: - * - Sets `this.modelOptions.model` to `gpt-4-vision-preview` if the request is a vision request. - * - Sets `this.isVisionModel` to `true` if vision request. - * - Deletes `this.modelOptions.stop` if vision request. + * `AgentClient` is not opinionated about vision requests, so we don't do anything here * @param {MongoFile[]} attachments */ - checkVisionRequest(attachments) { - // if (!attachments) { - // return; - // } - // const availableModels = this.options.modelsConfig?.[this.options.endpoint]; - // if (!availableModels) { - // return; - // } - // let visionRequestDetected = false; - // for (const file of attachments) { - // if (file?.type?.includes('image')) { - // visionRequestDetected = true; - // break; - // } - // } - // if (!visionRequestDetected) { - // return; - // } - // this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels }); - // if (this.isVisionModel) { - // delete this.modelOptions.stop; - // return; - // } - // for (const model of availableModels) { - // if (!validateVisionModel({ model, availableModels })) { - // continue; - // } - // this.modelOptions.model = model; - // this.isVisionModel = true; - // delete this.modelOptions.stop; - // return; - // } - // if (!availableModels.includes(this.defaultVisionModel)) { - // return; - // } - // if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) { - // return; - // } - // this.modelOptions.model = this.defaultVisionModel; - // this.isVisionModel = true; - // delete this.modelOptions.stop; - } + checkVisionRequest() {} getSaveOptions() { // TODO: @@ -270,24 +226,6 @@ class AgentClient extends BaseClient { .filter(Boolean) .join('\n') .trim(); - // this.systemMessage = getCurrentDateTime(); - // const { withKeys, withoutKeys } = await getFormattedMemories({ - // userId: this.options.req.user.id, - // }); - // processMemory({ - // userId: this.options.req.user.id, - // message: this.options.req.body.text, - // parentMessageId, - // memory: withKeys, - // thread_id: this.conversationId, - // }).catch((error) => { - // logger.error('Memory Agent failed to process memory', error); - // }); - - // this.systemMessage += '\n\n' + memoryInstructions; - // if (withoutKeys) { - // this.systemMessage += `\n\n# Existing memory about the user:\n${withoutKeys}`; - // } if (this.options.attachments) { const attachments = await this.options.attachments; @@ -431,9 +369,150 @@ class AgentClient extends BaseClient { opts.getReqData({ promptTokens }); } + const withoutKeys = await this.useMemory(); + if (withoutKeys) { + systemContent += `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`; + } + + if (systemContent) { + this.options.agent.instructions = systemContent; + } + return result; } + /** + * @returns {Promise} + */ + async useMemory() { + const user = this.options.req.user; + if (user.personalization?.memories === false) { + return; + } + const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]); + + if (!hasAccess) { + logger.debug( + `[api/server/controllers/agents/client.js #useMemory] User ${user.id} does not have USE permission for memories`, + ); + return; + } + /** @type {TCustomConfig['memory']} */ + const memoryConfig = this.options.req?.app?.locals?.memory; + if (!memoryConfig || memoryConfig.disabled === true) { + return; + } + + /** @type {Agent} */ + let prelimAgent; + const allowedProviders = new Set( + this.options.req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders, + ); + try { + if (memoryConfig.agent?.id != null && memoryConfig.agent.id !== this.options.agent.id) { + prelimAgent = await loadAgent({ + req: this.options.req, + agent_id: memoryConfig.agent.id, + endpoint: EModelEndpoint.agents, + }); + } else if ( + memoryConfig.agent?.id == null && + memoryConfig.agent?.model != null && + memoryConfig.agent?.provider != null + ) { + prelimAgent = { id: Constants.EPHEMERAL_AGENT_ID, ...memoryConfig.agent }; + } + } catch (error) { + logger.error( + '[api/server/controllers/agents/client.js #useMemory] Error loading agent for memory', + error, + ); + } + + const agent = await initializeAgent({ + req: this.options.req, + res: this.options.res, + agent: prelimAgent, + allowedProviders, + }); + + if (!agent) { + logger.warn( + '[api/server/controllers/agents/client.js #useMemory] No agent found for memory', + memoryConfig, + ); + return; + } + + const llmConfig = Object.assign( + { + provider: agent.provider, + model: agent.model, + }, + agent.model_parameters, + ); + + /** @type {import('@librechat/api').MemoryConfig} */ + const config = { + validKeys: memoryConfig.validKeys, + instructions: agent.instructions, + llmConfig, + tokenLimit: memoryConfig.tokenLimit, + }; + + const userId = this.options.req.user.id + ''; + const messageId = this.responseMessageId + ''; + const conversationId = this.conversationId + ''; + const [withoutKeys, processMemory] = await createMemoryProcessor({ + userId, + config, + messageId, + conversationId, + memoryMethods: { + setMemory, + deleteMemory, + getFormattedMemories, + }, + res: this.options.res, + }); + + this.processMemory = processMemory; + return withoutKeys; + } + + /** + * @param {BaseMessage[]} messages + * @returns {Promise} + */ + async runMemory(messages) { + try { + if (this.processMemory == null) { + return; + } + /** @type {TCustomConfig['memory']} */ + const memoryConfig = this.options.req?.app?.locals?.memory; + const messageWindowSize = memoryConfig?.messageWindowSize ?? 5; + + let messagesToProcess = [...messages]; + if (messages.length > messageWindowSize) { + for (let i = messages.length - messageWindowSize; i >= 0; i--) { + const potentialWindow = messages.slice(i, i + messageWindowSize); + if (potentialWindow[0]?.role === 'user') { + messagesToProcess = [...potentialWindow]; + break; + } + } + + if (messagesToProcess.length === messages.length) { + messagesToProcess = [...messages.slice(-messageWindowSize)]; + } + } + return await this.processMemory(messagesToProcess); + } catch (error) { + logger.error('Memory Agent failed to process memory', error); + } + } + /** @type {sendCompletion} */ async sendCompletion(payload, opts = {}) { await this.chatCompletion({ @@ -576,100 +655,13 @@ class AgentClient extends BaseClient { let config; /** @type {ReturnType} */ let run; + /** @type {Promise<(TAttachment | null)[] | undefined>} */ + let memoryPromise; try { if (!abortController) { abortController = new AbortController(); } - // if (this.options.headers) { - // opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers }; - // } - - // if (this.options.proxy) { - // opts.httpAgent = new HttpsProxyAgent(this.options.proxy); - // } - - // if (this.isVisionModel) { - // modelOptions.max_tokens = 4000; - // } - - // /** @type {TAzureConfig | undefined} */ - // const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI]; - - // if ( - // (this.azure && this.isVisionModel && azureConfig) || - // (azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI) - // ) { - // const { modelGroupMap, groupMap } = azureConfig; - // const { - // azureOptions, - // baseURL, - // headers = {}, - // serverless, - // } = mapModelToAzureConfig({ - // modelName: modelOptions.model, - // modelGroupMap, - // groupMap, - // }); - // opts.defaultHeaders = resolveHeaders(headers); - // this.langchainProxy = extractBaseURL(baseURL); - // this.apiKey = azureOptions.azureOpenAIApiKey; - - // const groupName = modelGroupMap[modelOptions.model].group; - // this.options.addParams = azureConfig.groupMap[groupName].addParams; - // this.options.dropParams = azureConfig.groupMap[groupName].dropParams; - // // Note: `forcePrompt` not re-assigned as only chat models are vision models - - // this.azure = !serverless && azureOptions; - // this.azureEndpoint = - // !serverless && genAzureChatCompletion(this.azure, modelOptions.model, this); - // } - - // if (this.azure || this.options.azure) { - // /* Azure Bug, extremely short default `max_tokens` response */ - // if (!modelOptions.max_tokens && modelOptions.model === 'gpt-4-vision-preview') { - // modelOptions.max_tokens = 4000; - // } - - // /* Azure does not accept `model` in the body, so we need to remove it. */ - // delete modelOptions.model; - - // opts.baseURL = this.langchainProxy - // ? constructAzureURL({ - // baseURL: this.langchainProxy, - // azureOptions: this.azure, - // }) - // : this.azureEndpoint.split(/(? { - // delete modelOptions[param]; - // }); - // logger.debug('[api/server/controllers/agents/client.js #chatCompletion] dropped params', { - // dropParams: this.options.dropParams, - // modelOptions, - // }); - // } - /** @type {TCustomConfig['endpoints']['agents']} */ const agentsEConfig = this.options.req.app.locals[EModelEndpoint.agents]; @@ -766,6 +758,10 @@ class AgentClient extends BaseClient { messages = addCacheControl(messages); } + if (i === 0) { + memoryPromise = this.runMemory(messages); + } + run = await createRun({ agent, req: this.options.req, @@ -801,10 +797,9 @@ class AgentClient extends BaseClient { run.Graph.contentData = contentData; } - const encoding = this.getEncoding(); await run.processStream({ messages }, config, { keepContent: i !== 0, - tokenCounter: createTokenCounter(encoding), + tokenCounter: createTokenCounter(this.getEncoding()), indexTokenCountMap: currentIndexCountMap, maxContextTokens: agent.maxContextTokens, callbacks: { @@ -919,6 +914,12 @@ class AgentClient extends BaseClient { }); try { + if (memoryPromise) { + const attachments = await memoryPromise; + if (attachments && attachments.length > 0) { + this.artifactPromises.push(...attachments); + } + } await this.recordCollectedUsage({ context: 'message' }); } catch (err) { logger.error( @@ -927,6 +928,12 @@ class AgentClient extends BaseClient { ); } } catch (err) { + if (memoryPromise) { + const attachments = await memoryPromise; + if (attachments && attachments.length > 0) { + this.artifactPromises.push(...attachments); + } + } logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', err, diff --git a/api/server/controllers/agents/run.js b/api/server/controllers/agents/run.js deleted file mode 100644 index 2452e66233..0000000000 --- a/api/server/controllers/agents/run.js +++ /dev/null @@ -1,94 +0,0 @@ -const { Run, Providers } = require('@librechat/agents'); -const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider'); - -/** - * @typedef {import('@librechat/agents').t} t - * @typedef {import('@librechat/agents').StandardGraphConfig} StandardGraphConfig - * @typedef {import('@librechat/agents').StreamEventData} StreamEventData - * @typedef {import('@librechat/agents').EventHandler} EventHandler - * @typedef {import('@librechat/agents').GraphEvents} GraphEvents - * @typedef {import('@librechat/agents').LLMConfig} LLMConfig - * @typedef {import('@librechat/agents').IState} IState - */ - -const customProviders = new Set([ - Providers.XAI, - Providers.OLLAMA, - Providers.DEEPSEEK, - Providers.OPENROUTER, -]); - -/** - * Creates a new Run instance with custom handlers and configuration. - * - * @param {Object} options - The options for creating the Run instance. - * @param {ServerRequest} [options.req] - The server request. - * @param {string | undefined} [options.runId] - Optional run ID; otherwise, a new run ID will be generated. - * @param {Agent} options.agent - The agent for this run. - * @param {AbortSignal} options.signal - The signal for this run. - * @param {Record | undefined} [options.customHandlers] - Custom event handlers. - * @param {boolean} [options.streaming=true] - Whether to use streaming. - * @param {boolean} [options.streamUsage=true] - Whether to stream usage information. - * @returns {Promise>} A promise that resolves to a new Run instance. - */ -async function createRun({ - runId, - agent, - signal, - customHandlers, - streaming = true, - streamUsage = true, -}) { - const provider = providerEndpointMap[agent.provider] ?? agent.provider; - /** @type {LLMConfig} */ - const llmConfig = Object.assign( - { - provider, - streaming, - streamUsage, - }, - agent.model_parameters, - ); - - /** Resolves issues with new OpenAI usage field */ - if ( - customProviders.has(agent.provider) || - (agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider) - ) { - llmConfig.streamUsage = false; - llmConfig.usage = true; - } - - /** @type {'reasoning_content' | 'reasoning'} */ - let reasoningKey; - if ( - llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter) || - (agent.endpoint && agent.endpoint.toLowerCase().includes(KnownEndpoints.openrouter)) - ) { - reasoningKey = 'reasoning'; - } - - /** @type {StandardGraphConfig} */ - const graphConfig = { - signal, - llmConfig, - reasoningKey, - tools: agent.tools, - instructions: agent.instructions, - additional_instructions: agent.additional_instructions, - // toolEnd: agent.end_after_tools, - }; - - // TEMPORARY FOR TESTING - if (agent.provider === Providers.ANTHROPIC || agent.provider === Providers.BEDROCK) { - graphConfig.streamBuffer = 2000; - } - - return Run.create({ - runId, - graphConfig, - customHandlers, - }); -} - -module.exports = { createRun }; diff --git a/api/server/index.js b/api/server/index.js index ed770f7703..a04c339b0f 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -117,7 +117,7 @@ const startServer = async () => { app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); app.use('/api/bedrock', routes.bedrock); - + app.use('/api/memories', routes.memories); app.use('/api/tags', routes.tags); app.use((req, res) => { diff --git a/api/server/middleware/roles/generateCheckAccess.js b/api/server/middleware/roles/access.js similarity index 100% rename from api/server/middleware/roles/generateCheckAccess.js rename to api/server/middleware/roles/access.js diff --git a/api/server/middleware/roles/checkAdmin.js b/api/server/middleware/roles/admin.js similarity index 100% rename from api/server/middleware/roles/checkAdmin.js rename to api/server/middleware/roles/admin.js diff --git a/api/server/middleware/roles/index.js b/api/server/middleware/roles/index.js index a9fc5b2a08..ebc0043f2f 100644 --- a/api/server/middleware/roles/index.js +++ b/api/server/middleware/roles/index.js @@ -1,5 +1,5 @@ -const checkAdmin = require('./checkAdmin'); -const { checkAccess, generateCheckAccess } = require('./generateCheckAccess'); +const checkAdmin = require('./admin'); +const { checkAccess, generateCheckAccess } = require('./access'); module.exports = { checkAdmin, diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index f23ecd2823..257c309fa2 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -2,8 +2,8 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); +const { sanitizeFilename } = require('@librechat/api'); const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider'); -const { sanitizeFilename } = require('~/server/utils/handleText'); const { getCustomConfig } = require('~/server/services/Config'); const storage = multer.diskStorage({ diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 449759383d..06e39d3671 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -4,6 +4,7 @@ const tokenizer = require('./tokenizer'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); +const memories = require('./memories'); const presets = require('./presets'); const prompts = require('./prompts'); const balance = require('./balance'); @@ -51,6 +52,7 @@ module.exports = { presets, balance, messages, + memories, endpoints, tokenizer, assistants, diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js new file mode 100644 index 0000000000..86065fecaa --- /dev/null +++ b/api/server/routes/memories.js @@ -0,0 +1,231 @@ +const express = require('express'); +const { Tokenizer } = require('@librechat/api'); +const { PermissionTypes, Permissions } = require('librechat-data-provider'); +const { + getAllUserMemories, + toggleUserMemories, + createMemory, + setMemory, + deleteMemory, +} = require('~/models'); +const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); + +const router = express.Router(); + +const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.READ, +]); +const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.CREATE, +]); +const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.UPDATE, +]); +const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.UPDATE, +]); +const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [ + Permissions.USE, + Permissions.OPT_OUT, +]); + +router.use(requireJwtAuth); + +/** + * GET /memories + * Returns all memories for the authenticated user, sorted by updated_at (newest first). + * Also includes memory usage percentage based on token limit. + */ +router.get('/', checkMemoryRead, async (req, res) => { + try { + const memories = await getAllUserMemories(req.user.id); + + const sortedMemories = memories.sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), + ); + + const totalTokens = memories.reduce((sum, memory) => { + return sum + (memory.tokenCount || 0); + }, 0); + + const memoryConfig = req.app.locals?.memory; + const tokenLimit = memoryConfig?.tokenLimit; + + let usagePercentage = null; + if (tokenLimit && tokenLimit > 0) { + usagePercentage = Math.min(100, Math.round((totalTokens / tokenLimit) * 100)); + } + + res.json({ + memories: sortedMemories, + totalTokens, + tokenLimit: tokenLimit || null, + usagePercentage, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /memories + * Creates a new memory entry for the authenticated user. + * Body: { key: string, value: string } + * Returns 201 and { created: true, memory: } when successful. + */ +router.post('/', checkMemoryCreate, async (req, res) => { + const { key, value } = req.body; + + if (typeof key !== 'string' || key.trim() === '') { + return res.status(400).json({ error: 'Key is required and must be a non-empty string.' }); + } + + if (typeof value !== 'string' || value.trim() === '') { + return res.status(400).json({ error: 'Value is required and must be a non-empty string.' }); + } + + try { + const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); + + const memories = await getAllUserMemories(req.user.id); + + // Check token limit + const memoryConfig = req.app.locals?.memory; + const tokenLimit = memoryConfig?.tokenLimit; + + if (tokenLimit) { + const currentTotalTokens = memories.reduce( + (sum, memory) => sum + (memory.tokenCount || 0), + 0, + ); + if (currentTotalTokens + tokenCount > tokenLimit) { + return res.status(400).json({ + error: `Adding this memory would exceed the token limit of ${tokenLimit}. Current usage: ${currentTotalTokens} tokens.`, + }); + } + } + + const result = await createMemory({ + userId: req.user.id, + key: key.trim(), + value: value.trim(), + tokenCount, + }); + + if (!result.ok) { + return res.status(500).json({ error: 'Failed to create memory.' }); + } + + const updatedMemories = await getAllUserMemories(req.user.id); + const newMemory = updatedMemories.find((m) => m.key === key.trim()); + + res.status(201).json({ created: true, memory: newMemory }); + } catch (error) { + if (error.message && error.message.includes('already exists')) { + return res.status(409).json({ error: 'Memory with this key already exists.' }); + } + res.status(500).json({ error: error.message }); + } +}); + +/** + * PATCH /memories/preferences + * Updates the user's memory preferences (e.g., enabling/disabling memories). + * Body: { memories: boolean } + * Returns 200 and { updated: true, preferences: { memories: boolean } } when successful. + */ +router.patch('/preferences', checkMemoryOptOut, async (req, res) => { + const { memories } = req.body; + + if (typeof memories !== 'boolean') { + return res.status(400).json({ error: 'memories must be a boolean value.' }); + } + + try { + const updatedUser = await toggleUserMemories(req.user.id, memories); + + if (!updatedUser) { + return res.status(404).json({ error: 'User not found.' }); + } + + res.json({ + updated: true, + preferences: { + memories: updatedUser.personalization?.memories ?? true, + }, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * PATCH /memories/:key + * Updates the value of an existing memory entry for the authenticated user. + * Body: { value: string } + * Returns 200 and { updated: true, memory: } when successful. + */ +router.patch('/:key', checkMemoryUpdate, async (req, res) => { + const { key } = req.params; + const { value } = req.body || {}; + + if (typeof value !== 'string' || value.trim() === '') { + return res.status(400).json({ error: 'Value is required and must be a non-empty string.' }); + } + + try { + const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); + + const memories = await getAllUserMemories(req.user.id); + const existingMemory = memories.find((m) => m.key === key); + + if (!existingMemory) { + return res.status(404).json({ error: 'Memory not found.' }); + } + + const result = await setMemory({ + userId: req.user.id, + key, + value, + tokenCount, + }); + + if (!result.ok) { + return res.status(500).json({ error: 'Failed to update memory.' }); + } + + const updatedMemories = await getAllUserMemories(req.user.id); + const updatedMemory = updatedMemories.find((m) => m.key === key); + + res.json({ updated: true, memory: updatedMemory }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** + * DELETE /memories/:key + * Deletes a memory entry for the authenticated user. + * Returns 200 and { deleted: true } when successful. + */ +router.delete('/:key', checkMemoryDelete, async (req, res) => { + const { key } = req.params; + + try { + const result = await deleteMemory({ userId: req.user.id, key }); + + if (!result.ok) { + return res.status(404).json({ error: 'Memory not found.' }); + } + + res.json({ deleted: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index 17768c7de6..aefbfcec0c 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -1,6 +1,7 @@ const express = require('express'); const { promptPermissionsSchema, + memoryPermissionsSchema, agentPermissionsSchema, PermissionTypes, roleDefaults, @@ -118,4 +119,43 @@ router.put('/:roleName/agents', checkAdmin, async (req, res) => { } }); +/** + * PUT /api/roles/:roleName/memories + * Update memory permissions for a specific role + */ +router.put('/:roleName/memories', checkAdmin, async (req, res) => { + const { roleName: _r } = req.params; + // TODO: TEMP, use a better parsing for roleName + const roleName = _r.toUpperCase(); + /** @type {TRole['permissions']['MEMORIES']} */ + const updates = req.body; + + try { + const parsedUpdates = memoryPermissionsSchema.partial().parse(updates); + + const role = await getRoleByName(roleName); + if (!role) { + return res.status(404).send({ message: 'Role not found' }); + } + + const currentPermissions = + role.permissions?.[PermissionTypes.MEMORIES] || role[PermissionTypes.MEMORIES] || {}; + + const mergedUpdates = { + permissions: { + ...role.permissions, + [PermissionTypes.MEMORIES]: { + ...currentPermissions, + ...parsedUpdates, + }, + }, + }; + + const updatedRole = await updateRoleByName(roleName, mergedUpdates); + res.status(200).send(updatedRole); + } catch (error) { + return res.status(400).send({ message: 'Invalid memory permissions.', error: error.errors }); + } +}); + module.exports = router; diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index a35c74ad74..ed9b0c447d 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -1,6 +1,8 @@ const jwt = require('jsonwebtoken'); const { nanoid } = require('nanoid'); +const { sendEvent } = require('@librechat/api'); const { tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { GraphEvents, sleep } = require('@librechat/agents'); const { Time, @@ -13,10 +15,10 @@ const { actionDomainSeparator, } = require('librechat-data-provider'); const { refreshAccessToken } = require('~/server/services/TokenService'); -const { logger, getFlowStateManager, sendEvent } = require('~/config'); const { encryptV2, decryptV2 } = require('~/server/utils/crypto'); const { getActions, deleteActions } = require('~/models/Action'); const { deleteAssistant } = require('~/models/Assistant'); +const { getFlowStateManager } = require('~/config'); const { logAxiosError } = require('~/utils'); const { getLogStores } = require('~/cache'); const { findToken } = require('~/models'); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 4bb8c51d00..dc686d6294 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -3,6 +3,7 @@ const { loadOCRConfig, processMCPEnv, EModelEndpoint, + loadMemoryConfig, getConfigDefaults, loadWebSearchConfig, } = require('librechat-data-provider'); @@ -44,6 +45,7 @@ const AppService = async (app) => { const ocr = loadOCRConfig(config.ocr); const webSearch = loadWebSearchConfig(config.webSearch); checkWebSearchConfig(webSearch); + const memory = loadMemoryConfig(config.memory); const filteredTools = config.filteredTools; const includedTools = config.includedTools; const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy; @@ -88,6 +90,7 @@ const AppService = async (app) => { const defaultLocals = { ocr, paths, + memory, webSearch, fileStrategy, socialLogins, diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js new file mode 100644 index 0000000000..13a42140db --- /dev/null +++ b/api/server/services/Endpoints/agents/agent.js @@ -0,0 +1,196 @@ +const { Providers } = require('@librechat/agents'); +const { primeResources, optionalChainWithEmptyCheck } = require('@librechat/api'); +const { + ErrorTypes, + EModelEndpoint, + EToolResources, + replaceSpecialVars, + providerEndpointMap, +} = require('librechat-data-provider'); +const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); +const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); +const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); +const initCustom = require('~/server/services/Endpoints/custom/initialize'); +const initGoogle = require('~/server/services/Endpoints/google/initialize'); +const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); +const { getCustomEndpointConfig } = require('~/server/services/Config'); +const { processFiles } = require('~/server/services/Files/process'); +const { getConvoFiles } = require('~/models/Conversation'); +const { getToolFilesByIds } = require('~/models/File'); +const { getModelMaxTokens } = require('~/utils'); +const { getFiles } = require('~/models/File'); + +const providerConfigMap = { + [Providers.XAI]: initCustom, + [Providers.OLLAMA]: initCustom, + [Providers.DEEPSEEK]: initCustom, + [Providers.OPENROUTER]: initCustom, + [EModelEndpoint.openAI]: initOpenAI, + [EModelEndpoint.google]: initGoogle, + [EModelEndpoint.azureOpenAI]: initOpenAI, + [EModelEndpoint.anthropic]: initAnthropic, + [EModelEndpoint.bedrock]: getBedrockOptions, +}; + +/** + * @param {object} params + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res + * @param {Agent} params.agent + * @param {string | null} [params.conversationId] + * @param {Array} [params.requestFiles] + * @param {typeof import('~/server/services/ToolService').loadAgentTools | undefined} [params.loadTools] + * @param {TEndpointOption} [params.endpointOption] + * @param {Set} [params.allowedProviders] + * @param {boolean} [params.isInitialAgent] + * @returns {Promise, toolContextMap: Record, maxContextTokens: number }>} + */ +const initializeAgent = async ({ + req, + res, + agent, + loadTools, + requestFiles, + conversationId, + endpointOption, + allowedProviders, + isInitialAgent = false, +}) => { + if (allowedProviders.size > 0 && !allowedProviders.has(agent.provider)) { + throw new Error( + `{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`, + ); + } + let currentFiles; + + if ( + isInitialAgent && + conversationId != null && + (agent.model_parameters?.resendFiles ?? true) === true + ) { + const fileIds = (await getConvoFiles(conversationId)) ?? []; + /** @type {Set} */ + const toolResourceSet = new Set(); + for (const tool of agent.tools) { + if (EToolResources[tool]) { + toolResourceSet.add(EToolResources[tool]); + } + } + const toolFiles = await getToolFilesByIds(fileIds, toolResourceSet); + if (requestFiles.length || toolFiles.length) { + currentFiles = await processFiles(requestFiles.concat(toolFiles)); + } + } else if (isInitialAgent && requestFiles.length) { + currentFiles = await processFiles(requestFiles); + } + + const { attachments, tool_resources } = await primeResources({ + req, + getFiles, + attachments: currentFiles, + tool_resources: agent.tool_resources, + requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), + }); + + const provider = agent.provider; + const { tools, toolContextMap } = + (await loadTools?.({ + req, + res, + provider, + agentId: agent.id, + tools: agent.tools, + model: agent.model, + tool_resources, + })) ?? {}; + + agent.endpoint = provider; + let getOptions = providerConfigMap[provider]; + if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) { + agent.provider = provider.toLowerCase(); + getOptions = providerConfigMap[agent.provider]; + } else if (!getOptions) { + const customEndpointConfig = await getCustomEndpointConfig(provider); + if (!customEndpointConfig) { + throw new Error(`Provider ${provider} not supported`); + } + getOptions = initCustom; + agent.provider = Providers.OPENAI; + } + const model_parameters = Object.assign( + {}, + agent.model_parameters ?? { model: agent.model }, + isInitialAgent === true ? endpointOption?.model_parameters : {}, + ); + const _endpointOption = + isInitialAgent === true + ? Object.assign({}, endpointOption, { model_parameters }) + : { model_parameters }; + + const options = await getOptions({ + req, + res, + optionsOnly: true, + overrideEndpoint: provider, + overrideModel: agent.model, + endpointOption: _endpointOption, + }); + + if ( + agent.endpoint === EModelEndpoint.azureOpenAI && + options.llmConfig?.azureOpenAIApiInstanceName == null + ) { + agent.provider = Providers.OPENAI; + } + + if (options.provider != null) { + agent.provider = options.provider; + } + + /** @type {import('@librechat/agents').ClientOptions} */ + agent.model_parameters = Object.assign(model_parameters, options.llmConfig); + if (options.configOptions) { + agent.model_parameters.configuration = options.configOptions; + } + + if (!agent.model_parameters.model) { + agent.model_parameters.model = agent.model; + } + + if (agent.instructions && agent.instructions !== '') { + agent.instructions = replaceSpecialVars({ + text: agent.instructions, + user: req.user, + }); + } + + if (typeof agent.artifacts === 'string' && agent.artifacts !== '') { + agent.additional_instructions = generateArtifactsPrompt({ + endpoint: agent.provider, + artifacts: agent.artifacts, + }); + } + + const tokensModel = + agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model; + const maxTokens = optionalChainWithEmptyCheck( + agent.model_parameters.maxOutputTokens, + agent.model_parameters.maxTokens, + 0, + ); + const maxContextTokens = optionalChainWithEmptyCheck( + agent.model_parameters.maxContextTokens, + agent.max_context_tokens, + getModelMaxTokens(tokensModel, providerEndpointMap[provider]), + 4096, + ); + return { + ...agent, + tools, + attachments, + toolContextMap, + maxContextTokens: (maxContextTokens - maxTokens) * 0.9, + }; +}; + +module.exports = { initializeAgent }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index c9e363e815..e3154fe13a 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,294 +1,41 @@ -const { createContentAggregator, Providers } = require('@librechat/agents'); -const { - Constants, - ErrorTypes, - EModelEndpoint, - EToolResources, - getResponseSender, - AgentCapabilities, - replaceSpecialVars, - providerEndpointMap, -} = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); +const { createContentAggregator } = require('@librechat/agents'); +const { Constants, EModelEndpoint, getResponseSender } = require('librechat-data-provider'); const { getDefaultHandlers, createToolEndCallback, } = require('~/server/controllers/agents/callbacks'); -const initAnthropic = require('~/server/services/Endpoints/anthropic/initialize'); -const getBedrockOptions = require('~/server/services/Endpoints/bedrock/options'); -const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); -const initCustom = require('~/server/services/Endpoints/custom/initialize'); -const initGoogle = require('~/server/services/Endpoints/google/initialize'); -const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); -const { getCustomEndpointConfig } = require('~/server/services/Config'); -const { processFiles } = require('~/server/services/Files/process'); +const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { loadAgentTools } = require('~/server/services/ToolService'); const AgentClient = require('~/server/controllers/agents/client'); -const { getConvoFiles } = require('~/models/Conversation'); -const { getToolFilesByIds } = require('~/models/File'); -const { getModelMaxTokens } = require('~/utils'); const { getAgent } = require('~/models/Agent'); -const { getFiles } = require('~/models/File'); -const { logger } = require('~/config'); -const providerConfigMap = { - [Providers.XAI]: initCustom, - [Providers.OLLAMA]: initCustom, - [Providers.DEEPSEEK]: initCustom, - [Providers.OPENROUTER]: initCustom, - [EModelEndpoint.openAI]: initOpenAI, - [EModelEndpoint.google]: initGoogle, - [EModelEndpoint.azureOpenAI]: initOpenAI, - [EModelEndpoint.anthropic]: initAnthropic, - [EModelEndpoint.bedrock]: getBedrockOptions, -}; - -/** - * @param {Object} params - * @param {ServerRequest} params.req - * @param {Promise> | undefined} [params.attachments] - * @param {Set} params.requestFileSet - * @param {AgentToolResources | undefined} [params.tool_resources] - * @returns {Promise<{ attachments: Array | undefined, tool_resources: AgentToolResources | undefined }>} - */ -const primeResources = async ({ - req, - attachments: _attachments, - tool_resources: _tool_resources, - requestFileSet, -}) => { - try { - /** @type {Array | undefined} */ - let attachments; - const tool_resources = _tool_resources ?? {}; - const isOCREnabled = (req.app.locals?.[EModelEndpoint.agents]?.capabilities ?? []).includes( - AgentCapabilities.ocr, - ); - if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) { - const context = await getFiles( - { - file_id: { $in: tool_resources.ocr.file_ids }, - }, - {}, - {}, - ); - attachments = (attachments ?? []).concat(context); +function createToolLoader() { + /** + * @param {object} params + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res + * @param {string} params.agentId + * @param {string[]} params.tools + * @param {string} params.provider + * @param {string} params.model + * @param {AgentToolResources} params.tool_resources + * @returns {Promise<{ tools: StructuredTool[], toolContextMap: Record } | undefined>} + */ + return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) { + const agent = { id: agentId, tools, provider, model }; + try { + return await loadAgentTools({ + req, + res, + agent, + tool_resources, + }); + } catch (error) { + logger.error('Error loading tools for agent ' + agentId, error); } - if (!_attachments) { - return { attachments, tool_resources }; - } - /** @type {Array | undefined} */ - const files = await _attachments; - if (!attachments) { - /** @type {Array} */ - attachments = []; - } - - for (const file of files) { - if (!file) { - continue; - } - if (file.metadata?.fileIdentifier) { - const execute_code = tool_resources[EToolResources.execute_code] ?? {}; - if (!execute_code.files) { - tool_resources[EToolResources.execute_code] = { ...execute_code, files: [] }; - } - tool_resources[EToolResources.execute_code].files.push(file); - } else if (file.embedded === true) { - const file_search = tool_resources[EToolResources.file_search] ?? {}; - if (!file_search.files) { - tool_resources[EToolResources.file_search] = { ...file_search, files: [] }; - } - tool_resources[EToolResources.file_search].files.push(file); - } else if ( - requestFileSet.has(file.file_id) && - file.type.startsWith('image') && - file.height && - file.width - ) { - const image_edit = tool_resources[EToolResources.image_edit] ?? {}; - if (!image_edit.files) { - tool_resources[EToolResources.image_edit] = { ...image_edit, files: [] }; - } - tool_resources[EToolResources.image_edit].files.push(file); - } - - attachments.push(file); - } - return { attachments, tool_resources }; - } catch (error) { - logger.error('Error priming resources', error); - return { attachments: _attachments, tool_resources: _tool_resources }; - } -}; - -/** - * @param {...string | number} values - * @returns {string | number | undefined} - */ -function optionalChainWithEmptyCheck(...values) { - for (const value of values) { - if (value !== undefined && value !== null && value !== '') { - return value; - } - } - return values[values.length - 1]; -} - -/** - * @param {object} params - * @param {ServerRequest} params.req - * @param {ServerResponse} params.res - * @param {Agent} params.agent - * @param {Set} [params.allowedProviders] - * @param {object} [params.endpointOption] - * @param {boolean} [params.isInitialAgent] - * @returns {Promise} - */ -const initializeAgentOptions = async ({ - req, - res, - agent, - endpointOption, - allowedProviders, - isInitialAgent = false, -}) => { - if (allowedProviders.size > 0 && !allowedProviders.has(agent.provider)) { - throw new Error( - `{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`, - ); - } - let currentFiles; - /** @type {Array} */ - const requestFiles = req.body.files ?? []; - if ( - isInitialAgent && - req.body.conversationId != null && - (agent.model_parameters?.resendFiles ?? true) === true - ) { - const fileIds = (await getConvoFiles(req.body.conversationId)) ?? []; - /** @type {Set} */ - const toolResourceSet = new Set(); - for (const tool of agent.tools) { - if (EToolResources[tool]) { - toolResourceSet.add(EToolResources[tool]); - } - } - const toolFiles = await getToolFilesByIds(fileIds, toolResourceSet); - if (requestFiles.length || toolFiles.length) { - currentFiles = await processFiles(requestFiles.concat(toolFiles)); - } - } else if (isInitialAgent && requestFiles.length) { - currentFiles = await processFiles(requestFiles); - } - - const { attachments, tool_resources } = await primeResources({ - req, - attachments: currentFiles, - tool_resources: agent.tool_resources, - requestFileSet: new Set(requestFiles.map((file) => file.file_id)), - }); - - const provider = agent.provider; - const { tools, toolContextMap } = await loadAgentTools({ - req, - res, - agent: { - id: agent.id, - tools: agent.tools, - provider, - model: agent.model, - }, - tool_resources, - }); - - agent.endpoint = provider; - let getOptions = providerConfigMap[provider]; - if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) { - agent.provider = provider.toLowerCase(); - getOptions = providerConfigMap[agent.provider]; - } else if (!getOptions) { - const customEndpointConfig = await getCustomEndpointConfig(provider); - if (!customEndpointConfig) { - throw new Error(`Provider ${provider} not supported`); - } - getOptions = initCustom; - agent.provider = Providers.OPENAI; - } - const model_parameters = Object.assign( - {}, - agent.model_parameters ?? { model: agent.model }, - isInitialAgent === true ? endpointOption?.model_parameters : {}, - ); - const _endpointOption = - isInitialAgent === true - ? Object.assign({}, endpointOption, { model_parameters }) - : { model_parameters }; - - const options = await getOptions({ - req, - res, - optionsOnly: true, - overrideEndpoint: provider, - overrideModel: agent.model, - endpointOption: _endpointOption, - }); - - if ( - agent.endpoint === EModelEndpoint.azureOpenAI && - options.llmConfig?.azureOpenAIApiInstanceName == null - ) { - agent.provider = Providers.OPENAI; - } - - if (options.provider != null) { - agent.provider = options.provider; - } - - /** @type {import('@librechat/agents').ClientOptions} */ - agent.model_parameters = Object.assign(model_parameters, options.llmConfig); - if (options.configOptions) { - agent.model_parameters.configuration = options.configOptions; - } - - if (!agent.model_parameters.model) { - agent.model_parameters.model = agent.model; - } - - if (agent.instructions && agent.instructions !== '') { - agent.instructions = replaceSpecialVars({ - text: agent.instructions, - user: req.user, - }); - } - - if (typeof agent.artifacts === 'string' && agent.artifacts !== '') { - agent.additional_instructions = generateArtifactsPrompt({ - endpoint: agent.provider, - artifacts: agent.artifacts, - }); - } - - const tokensModel = - agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model; - const maxTokens = optionalChainWithEmptyCheck( - agent.model_parameters.maxOutputTokens, - agent.model_parameters.maxTokens, - 0, - ); - const maxContextTokens = optionalChainWithEmptyCheck( - agent.model_parameters.maxContextTokens, - agent.max_context_tokens, - getModelMaxTokens(tokensModel, providerEndpointMap[provider]), - 4096, - ); - return { - ...agent, - tools, - attachments, - toolContextMap, - maxContextTokens: (maxContextTokens - maxTokens) * 0.9, }; -}; +} const initializeClient = async ({ req, res, endpointOption }) => { if (!endpointOption) { @@ -313,7 +60,6 @@ const initializeClient = async ({ req, res, endpointOption }) => { throw new Error('No agent promise provided'); } - // Initialize primary agent const primaryAgent = await endpointOption.agent; if (!primaryAgent) { throw new Error('Agent not found'); @@ -323,10 +69,18 @@ const initializeClient = async ({ req, res, endpointOption }) => { /** @type {Set} */ const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders); - // Handle primary agent - const primaryConfig = await initializeAgentOptions({ + const loadTools = createToolLoader(); + /** @type {Array} */ + const requestFiles = req.body.files ?? []; + /** @type {string} */ + const conversationId = req.body.conversationId; + + const primaryConfig = await initializeAgent({ req, res, + loadTools, + requestFiles, + conversationId, agent: primaryAgent, endpointOption, allowedProviders, @@ -340,10 +94,13 @@ const initializeClient = async ({ req, res, endpointOption }) => { if (!agent) { throw new Error(`Agent ${agentId} not found`); } - const config = await initializeAgentOptions({ + const config = await initializeAgent({ req, res, agent, + loadTools, + requestFiles, + conversationId, endpointOption, allowedProviders, }); diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js index fc8024af07..88acef23e5 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.js @@ -1,5 +1,6 @@ const OpenAI = require('openai'); const { HttpsProxyAgent } = require('https-proxy-agent'); +const { constructAzureURL, isUserProvided } = require('@librechat/api'); const { ErrorTypes, EModelEndpoint, @@ -12,8 +13,6 @@ const { checkUserKeyExpiry, } = require('~/server/services/UserService'); const OpenAIClient = require('~/app/clients/OpenAIClient'); -const { isUserProvided } = require('~/server/utils'); -const { constructAzureURL } = require('~/utils'); class Files { constructor(client) { diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index da332060e9..fc5536abbf 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -1,4 +1,5 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); +const { createHandleLLMNewToken } = require('@librechat/api'); const { AuthType, Constants, @@ -8,7 +9,6 @@ const { removeNullishValues, } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { createHandleLLMNewToken } = require('~/app/clients/generators'); const getOptions = async ({ req, overrideModel, endpointOption }) => { const { diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 39def8d0d5..754abef5a8 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -6,10 +6,9 @@ const { extractEnvVariable, } = require('librechat-data-provider'); const { Providers } = require('@librechat/agents'); +const { getOpenAIConfig, createHandleLLMNewToken } = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { getLLMConfig } = require('~/server/services/Endpoints/openAI/llm'); const { getCustomEndpointConfig } = require('~/server/services/Config'); -const { createHandleLLMNewToken } = require('~/app/clients/generators'); const { fetchModels } = require('~/server/services/ModelService'); const OpenAIClient = require('~/app/clients/OpenAIClient'); const { isUserProvided } = require('~/server/utils'); @@ -144,7 +143,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid clientOptions, ); clientOptions.modelOptions.user = req.user.id; - const options = getLLMConfig(apiKey, clientOptions, endpoint); + const options = getOpenAIConfig(apiKey, clientOptions, endpoint); if (!customOptions.streamRate) { return options; } diff --git a/api/server/services/Endpoints/gptPlugins/initialize.js b/api/server/services/Endpoints/gptPlugins/initialize.js index 7bfb43f004..d2af6c757e 100644 --- a/api/server/services/Endpoints/gptPlugins/initialize.js +++ b/api/server/services/Endpoints/gptPlugins/initialize.js @@ -1,11 +1,10 @@ const { EModelEndpoint, - mapModelToAzureConfig, resolveHeaders, + mapModelToAzureConfig, } = require('librechat-data-provider'); +const { isEnabled, isUserProvided, getAzureCredentials } = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { isEnabled, isUserProvided } = require('~/server/utils'); -const { getAzureCredentials } = require('~/utils'); const { PluginsClient } = require('~/app'); const initializeClient = async ({ req, res, endpointOption }) => { diff --git a/api/server/services/Endpoints/gptPlugins/initialize.spec.js b/api/server/services/Endpoints/gptPlugins/initialize.spec.js index 02199c9397..f9cb2750a4 100644 --- a/api/server/services/Endpoints/gptPlugins/initialize.spec.js +++ b/api/server/services/Endpoints/gptPlugins/initialize.spec.js @@ -114,11 +114,11 @@ describe('gptPlugins/initializeClient', () => { test('should initialize PluginsClient with Azure credentials when PLUGINS_USE_AZURE is true', async () => { process.env.AZURE_API_KEY = 'test-azure-api-key'; (process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_VERSION = 'some-value'), - (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), - (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), - (process.env.PLUGINS_USE_AZURE = 'true'); + (process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'some-value'), + (process.env.AZURE_OPENAI_API_VERSION = 'some-value'), + (process.env.AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME = 'some-value'), + (process.env.AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME = 'some-value'), + (process.env.PLUGINS_USE_AZURE = 'true'); process.env.DEBUG_PLUGINS = 'false'; process.env.OPENAI_SUMMARIZE = 'false'; diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index 714ed5a1e6..bc0907b3de 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -4,12 +4,15 @@ const { resolveHeaders, mapModelToAzureConfig, } = require('librechat-data-provider'); +const { + isEnabled, + isUserProvided, + getOpenAIConfig, + getAzureCredentials, + createHandleLLMNewToken, +} = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); -const { getLLMConfig } = require('~/server/services/Endpoints/openAI/llm'); -const { createHandleLLMNewToken } = require('~/app/clients/generators'); -const { isEnabled, isUserProvided } = require('~/server/utils'); const OpenAIClient = require('~/app/clients/OpenAIClient'); -const { getAzureCredentials } = require('~/utils'); const initializeClient = async ({ req, @@ -140,7 +143,7 @@ const initializeClient = async ({ modelOptions.model = modelName; clientOptions = Object.assign({ modelOptions }, clientOptions); clientOptions.modelOptions.user = req.user.id; - const options = getLLMConfig(apiKey, clientOptions); + const options = getOpenAIConfig(apiKey, clientOptions); const streamRate = clientOptions.streamRate; if (!streamRate) { return options; diff --git a/api/server/services/Endpoints/openAI/llm.js b/api/server/services/Endpoints/openAI/llm.js deleted file mode 100644 index c1fd090b28..0000000000 --- a/api/server/services/Endpoints/openAI/llm.js +++ /dev/null @@ -1,170 +0,0 @@ -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { KnownEndpoints } = require('librechat-data-provider'); -const { sanitizeModelName, constructAzureURL } = require('~/utils'); -const { isEnabled } = require('~/server/utils'); - -/** - * Generates configuration options for creating a language model (LLM) instance. - * @param {string} apiKey - The API key for authentication. - * @param {Object} options - Additional options for configuring the LLM. - * @param {Object} [options.modelOptions] - Model-specific options. - * @param {string} [options.modelOptions.model] - The name of the model to use. - * @param {string} [options.modelOptions.user] - The user ID - * @param {number} [options.modelOptions.temperature] - Controls randomness in output generation (0-2). - * @param {number} [options.modelOptions.top_p] - Controls diversity via nucleus sampling (0-1). - * @param {number} [options.modelOptions.frequency_penalty] - Reduces repetition of token sequences (-2 to 2). - * @param {number} [options.modelOptions.presence_penalty] - Encourages discussing new topics (-2 to 2). - * @param {number} [options.modelOptions.max_tokens] - The maximum number of tokens to generate. - * @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens. - * @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used. - * @param {boolean} [options.useOpenRouter] - Flag to use OpenRouter API. - * @param {Object} [options.headers] - Additional headers for API requests. - * @param {string} [options.proxy] - Proxy server URL. - * @param {Object} [options.azure] - Azure-specific configurations. - * @param {boolean} [options.streaming] - Whether to use streaming mode. - * @param {Object} [options.addParams] - Additional parameters to add to the model options. - * @param {string[]} [options.dropParams] - Parameters to remove from the model options. - * @param {string|null} [endpoint=null] - The endpoint name - * @returns {Object} Configuration options for creating an LLM instance. - */ -function getLLMConfig(apiKey, options = {}, endpoint = null) { - let { - modelOptions = {}, - reverseProxyUrl, - defaultQuery, - headers, - proxy, - azure, - streaming = true, - addParams, - dropParams, - } = options; - - /** @type {OpenAIClientOptions} */ - let llmConfig = { - streaming, - }; - - Object.assign(llmConfig, modelOptions); - - if (addParams && typeof addParams === 'object') { - Object.assign(llmConfig, addParams); - } - /** Note: OpenAI Web Search models do not support any known parameters besdies `max_tokens` */ - if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model)) { - const searchExcludeParams = [ - 'frequency_penalty', - 'presence_penalty', - 'temperature', - 'top_p', - 'top_k', - 'stop', - 'logit_bias', - 'seed', - 'response_format', - 'n', - 'logprobs', - 'user', - ]; - - dropParams = dropParams || []; - dropParams = [...new Set([...dropParams, ...searchExcludeParams])]; - } - - if (dropParams && Array.isArray(dropParams)) { - dropParams.forEach((param) => { - if (llmConfig[param]) { - llmConfig[param] = undefined; - } - }); - } - - let useOpenRouter; - /** @type {OpenAIClientOptions['configuration']} */ - const configOptions = {}; - if ( - (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter)) || - (endpoint && endpoint.toLowerCase().includes(KnownEndpoints.openrouter)) - ) { - useOpenRouter = true; - llmConfig.include_reasoning = true; - configOptions.baseURL = reverseProxyUrl; - configOptions.defaultHeaders = Object.assign( - { - 'HTTP-Referer': 'https://librechat.ai', - 'X-Title': 'LibreChat', - }, - headers, - ); - } else if (reverseProxyUrl) { - configOptions.baseURL = reverseProxyUrl; - if (headers) { - configOptions.defaultHeaders = headers; - } - } - - if (defaultQuery) { - configOptions.defaultQuery = defaultQuery; - } - - if (proxy) { - const proxyAgent = new HttpsProxyAgent(proxy); - Object.assign(configOptions, { - httpAgent: proxyAgent, - httpsAgent: proxyAgent, - }); - } - - if (azure) { - const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME); - azure.azureOpenAIApiDeploymentName = useModelName - ? sanitizeModelName(llmConfig.model) - : azure.azureOpenAIApiDeploymentName; - - if (process.env.AZURE_OPENAI_DEFAULT_MODEL) { - llmConfig.model = process.env.AZURE_OPENAI_DEFAULT_MODEL; - } - - if (configOptions.baseURL) { - const azureURL = constructAzureURL({ - baseURL: configOptions.baseURL, - azureOptions: azure, - }); - azure.azureOpenAIBasePath = azureURL.split(`/${azure.azureOpenAIApiDeploymentName}`)[0]; - } - - Object.assign(llmConfig, azure); - llmConfig.model = llmConfig.azureOpenAIApiDeploymentName; - } else { - llmConfig.apiKey = apiKey; - // Object.assign(llmConfig, { - // configuration: { apiKey }, - // }); - } - - if (process.env.OPENAI_ORGANIZATION && this.azure) { - llmConfig.organization = process.env.OPENAI_ORGANIZATION; - } - - if (useOpenRouter && llmConfig.reasoning_effort != null) { - llmConfig.reasoning = { - effort: llmConfig.reasoning_effort, - }; - delete llmConfig.reasoning_effort; - } - - if (llmConfig?.['max_tokens'] != null) { - /** @type {number} */ - llmConfig.maxTokens = llmConfig['max_tokens']; - delete llmConfig['max_tokens']; - } - - return { - /** @type {OpenAIClientOptions} */ - llmConfig, - /** @type {OpenAIClientOptions['configuration']} */ - configOptions, - }; -} - -module.exports = { getLLMConfig }; diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index d6c8cc4146..49a800336b 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -2,9 +2,9 @@ const axios = require('axios'); const fs = require('fs').promises; const FormData = require('form-data'); const { Readable } = require('stream'); +const { genAzureEndpoint } = require('@librechat/api'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { getCustomConfig } = require('~/server/services/Config'); -const { genAzureEndpoint } = require('~/utils'); const { logger } = require('~/config'); /** diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index cd718fdfc1..34d8202156 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -1,8 +1,8 @@ const axios = require('axios'); +const { genAzureEndpoint } = require('@librechat/api'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); const { getCustomConfig } = require('~/server/services/Config'); -const { genAzureEndpoint } = require('~/utils'); const { logger } = require('~/config'); /** diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index b9baef462e..dc3d3f0037 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,6 +1,6 @@ const { z } = require('zod'); const { tool } = require('@langchain/core/tools'); -const { normalizeServerName } = require('librechat-mcp'); +const { normalizeServerName } = require('@librechat/api'); const { Constants: AgentConstants, Providers } = require('@librechat/agents'); const { Constants, diff --git a/api/server/services/start/interface.js b/api/server/services/start/interface.js index 7578c036b2..c98fdb60bc 100644 --- a/api/server/services/start/interface.js +++ b/api/server/services/start/interface.js @@ -2,6 +2,7 @@ const { SystemRoles, Permissions, PermissionTypes, + isMemoryEnabled, removeNullishValues, } = require('librechat-data-provider'); const { updateAccessPermissions } = require('~/models/Role'); @@ -20,6 +21,14 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol const hasModelSpecs = config?.modelSpecs?.list?.length > 0; const includesAddedEndpoints = config?.modelSpecs?.addedEndpoints?.length > 0; + const memoryConfig = config?.memory; + const memoryEnabled = isMemoryEnabled(memoryConfig); + /** Only disable memories if memory config is present but disabled/invalid */ + const shouldDisableMemories = memoryConfig && !memoryEnabled; + /** Check if personalization is enabled (defaults to true if memory is configured and enabled) */ + const isPersonalizationEnabled = + memoryConfig && memoryEnabled && memoryConfig.personalize !== false; + /** @type {TCustomConfig['interface']} */ const loadedInterface = removeNullishValues({ endpointsMenu: @@ -33,6 +42,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks, + memories: shouldDisableMemories ? false : (interfaceConfig?.memories ?? defaults.memories), prompts: interfaceConfig?.prompts ?? defaults.prompts, multiConvo: interfaceConfig?.multiConvo ?? defaults.multiConvo, agents: interfaceConfig?.agents ?? defaults.agents, @@ -45,6 +55,10 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol await updateAccessPermissions(roleName, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: loadedInterface.memories, + [Permissions.OPT_OUT]: isPersonalizationEnabled, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo }, [PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, @@ -54,6 +68,10 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol await updateAccessPermissions(SystemRoles.ADMIN, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: loadedInterface.bookmarks }, + [PermissionTypes.MEMORIES]: { + [Permissions.USE]: loadedInterface.memories, + [Permissions.OPT_OUT]: isPersonalizationEnabled, + }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: loadedInterface.multiConvo }, [PermissionTypes.AGENTS]: { [Permissions.USE]: loadedInterface.agents }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat }, diff --git a/api/server/services/start/interface.spec.js b/api/server/services/start/interface.spec.js index d0dcfaf55f..1a05c9cf12 100644 --- a/api/server/services/start/interface.spec.js +++ b/api/server/services/start/interface.spec.js @@ -12,6 +12,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: true, + memories: true, multiConvo: true, agents: true, temporaryChat: true, @@ -26,6 +27,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -39,6 +41,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: false, bookmarks: false, + memories: false, multiConvo: false, agents: false, temporaryChat: false, @@ -53,6 +56,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: false }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, @@ -70,6 +74,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -83,6 +88,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: undefined, bookmarks: undefined, + memories: undefined, multiConvo: undefined, agents: undefined, temporaryChat: undefined, @@ -97,6 +103,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -110,6 +117,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: false, + memories: true, multiConvo: undefined, agents: true, temporaryChat: undefined, @@ -124,6 +132,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -138,6 +147,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: true, + memories: true, multiConvo: true, agents: true, temporaryChat: true, @@ -151,6 +161,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -168,6 +179,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -185,6 +197,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -202,6 +215,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -215,6 +229,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: false, + memories: true, multiConvo: true, agents: false, temporaryChat: true, @@ -228,6 +243,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -242,6 +258,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: true, + memories: false, multiConvo: false, agents: undefined, temporaryChat: undefined, @@ -255,6 +272,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: false }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: undefined }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined }, @@ -268,6 +286,7 @@ describe('loadDefaultInterface', () => { interface: { prompts: true, bookmarks: false, + memories: true, multiConvo: true, agents: false, temporaryChat: true, @@ -281,6 +300,7 @@ describe('loadDefaultInterface', () => { expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, + [PermissionTypes.MEMORIES]: { [Permissions.USE]: true }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 86c17f1dda..680da5da44 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -1,5 +1,3 @@ -const path = require('path'); -const crypto = require('crypto'); const { Capabilities, EModelEndpoint, @@ -218,38 +216,6 @@ function normalizeEndpointName(name = '') { return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name; } -/** - * Sanitize a filename by removing any directory components, replacing non-alphanumeric characters - * @param {string} inputName - * @returns {string} - */ -function sanitizeFilename(inputName) { - // Remove any directory components - let name = path.basename(inputName); - - // Replace any non-alphanumeric characters except for '.' and '-' - name = name.replace(/[^a-zA-Z0-9.-]/g, '_'); - - // Ensure the name doesn't start with a dot (hidden file in Unix-like systems) - if (name.startsWith('.') || name === '') { - name = '_' + name; - } - - // Limit the length of the filename - const MAX_LENGTH = 255; - if (name.length > MAX_LENGTH) { - const ext = path.extname(name); - const nameWithoutExt = path.basename(name, ext); - name = - nameWithoutExt.slice(0, MAX_LENGTH - ext.length - 7) + - '-' + - crypto.randomBytes(3).toString('hex') + - ext; - } - - return name; -} - module.exports = { isEnabled, handleText, @@ -260,6 +226,5 @@ module.exports = { generateConfig, addSpaceIfNeeded, createOnProgress, - sanitizeFilename, normalizeEndpointName, }; diff --git a/api/server/utils/handleText.spec.js b/api/server/utils/handleText.spec.js deleted file mode 100644 index 2cd6c51f91..0000000000 --- a/api/server/utils/handleText.spec.js +++ /dev/null @@ -1,103 +0,0 @@ -const { isEnabled, sanitizeFilename } = require('./handleText'); - -describe('isEnabled', () => { - test('should return true when input is "true"', () => { - expect(isEnabled('true')).toBe(true); - }); - - test('should return true when input is "TRUE"', () => { - expect(isEnabled('TRUE')).toBe(true); - }); - - test('should return true when input is true', () => { - expect(isEnabled(true)).toBe(true); - }); - - test('should return false when input is "false"', () => { - expect(isEnabled('false')).toBe(false); - }); - - test('should return false when input is false', () => { - expect(isEnabled(false)).toBe(false); - }); - - test('should return false when input is null', () => { - expect(isEnabled(null)).toBe(false); - }); - - test('should return false when input is undefined', () => { - expect(isEnabled()).toBe(false); - }); - - test('should return false when input is an empty string', () => { - expect(isEnabled('')).toBe(false); - }); - - test('should return false when input is a whitespace string', () => { - expect(isEnabled(' ')).toBe(false); - }); - - test('should return false when input is a number', () => { - expect(isEnabled(123)).toBe(false); - }); - - test('should return false when input is an object', () => { - expect(isEnabled({})).toBe(false); - }); - - test('should return false when input is an array', () => { - expect(isEnabled([])).toBe(false); - }); -}); - -jest.mock('crypto', () => { - const actualModule = jest.requireActual('crypto'); - return { - ...actualModule, - randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')), - }; -}); - -describe('sanitizeFilename', () => { - test('removes directory components (1/2)', () => { - expect(sanitizeFilename('/path/to/file.txt')).toBe('file.txt'); - }); - - test('removes directory components (2/2)', () => { - expect(sanitizeFilename('../../../../file.txt')).toBe('file.txt'); - }); - - test('replaces non-alphanumeric characters', () => { - expect(sanitizeFilename('file name@#$.txt')).toBe('file_name___.txt'); - }); - - test('preserves dots and hyphens', () => { - expect(sanitizeFilename('file-name.with.dots.txt')).toBe('file-name.with.dots.txt'); - }); - - test('prepends underscore to filenames starting with a dot', () => { - expect(sanitizeFilename('.hiddenfile')).toBe('_.hiddenfile'); - }); - - test('truncates long filenames', () => { - const longName = 'a'.repeat(300) + '.txt'; - const result = sanitizeFilename(longName); - expect(result.length).toBe(255); - expect(result).toMatch(/^a+-abc123\.txt$/); - }); - - test('handles filenames with no extension', () => { - const longName = 'a'.repeat(300); - const result = sanitizeFilename(longName); - expect(result.length).toBe(255); - expect(result).toMatch(/^a+-abc123$/); - }); - - test('handles empty input', () => { - expect(sanitizeFilename('')).toBe('_'); - }); - - test('handles input with only special characters', () => { - expect(sanitizeFilename('@#$%^&*')).toBe('_______'); - }); -}); diff --git a/api/typedefs.js b/api/typedefs.js index 8da5b34809..5bc7ebf664 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1073,7 +1073,7 @@ /** * @exports MCPServers - * @typedef {import('librechat-mcp').MCPServers} MCPServers + * @typedef {import('@librechat/api').MCPServers} MCPServers * @memberof typedefs */ @@ -1085,31 +1085,31 @@ /** * @exports MCPManager - * @typedef {import('librechat-mcp').MCPManager} MCPManager + * @typedef {import('@librechat/api').MCPManager} MCPManager * @memberof typedefs */ /** * @exports FlowStateManager - * @typedef {import('librechat-mcp').FlowStateManager} FlowStateManager + * @typedef {import('@librechat/api').FlowStateManager} FlowStateManager * @memberof typedefs */ /** * @exports LCAvailableTools - * @typedef {import('librechat-mcp').LCAvailableTools} LCAvailableTools + * @typedef {import('@librechat/api').LCAvailableTools} LCAvailableTools * @memberof typedefs */ /** * @exports LCTool - * @typedef {import('librechat-mcp').LCTool} LCTool + * @typedef {import('@librechat/api').LCTool} LCTool * @memberof typedefs */ /** * @exports FormattedContent - * @typedef {import('librechat-mcp').FormattedContent} FormattedContent + * @typedef {import('@librechat/api').FormattedContent} FormattedContent * @memberof typedefs */ @@ -1232,7 +1232,7 @@ * @typedef {Object} AgentClientOptions * @property {Agent} agent - The agent configuration object * @property {string} endpoint - The endpoint identifier for the agent - * @property {Object} req - The request object + * @property {ServerRequest} req - The request object * @property {string} [name] - The username * @property {string} [modelLabel] - The label for the model being used * @property {number} [maxContextTokens] - Maximum number of tokens allowed in context diff --git a/api/utils/index.js b/api/utils/index.js index 62d61586bf..6a304efd6d 100644 --- a/api/utils/index.js +++ b/api/utils/index.js @@ -1,7 +1,6 @@ const loadYaml = require('./loadYaml'); const axiosHelpers = require('./axios'); const tokenHelpers = require('./tokens'); -const azureUtils = require('./azureUtils'); const deriveBaseURL = require('./deriveBaseURL'); const extractBaseURL = require('./extractBaseURL'); const findMessageContent = require('./findMessageContent'); @@ -10,7 +9,6 @@ module.exports = { loadYaml, deriveBaseURL, extractBaseURL, - ...azureUtils, ...axiosHelpers, ...tokenHelpers, findMessageContent, diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 8c1ff2ebb5..0a1b4616a0 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -9,6 +9,7 @@ import type { } from 'librechat-data-provider'; import { ThinkingButton } from '~/components/Artifacts/Thinking'; import { MessageContext, SearchContext } from '~/Providers'; +import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; import useLocalize from '~/hooks/useLocalize'; import { mapAttachments } from '~/utils/map'; @@ -72,6 +73,7 @@ const ContentParts = memo( return hasThinkPart && allThinkPartsHaveContent; }, [content]); + if (!content) { return null; } @@ -103,6 +105,7 @@ const ContentParts = memo( return ( <> + {hasReasoningParts && (
diff --git a/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx b/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx new file mode 100644 index 0000000000..7af4e9fcdd --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx @@ -0,0 +1,143 @@ +import { Tools } from 'librechat-data-provider'; +import { useState, useRef, useMemo, useLayoutEffect, useEffect } from 'react'; +import type { MemoryArtifact, TAttachment } from 'librechat-data-provider'; +import MemoryInfo from './MemoryInfo'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +export default function MemoryArtifacts({ attachments }: { attachments?: TAttachment[] }) { + const localize = useLocalize(); + const [showInfo, setShowInfo] = useState(false); + const contentRef = useRef(null); + const [contentHeight, setContentHeight] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const prevShowInfoRef = useRef(showInfo); + + const memoryArtifacts = useMemo(() => { + const result: MemoryArtifact[] = []; + for (const attachment of attachments ?? []) { + if (attachment?.[Tools.memory] != null) { + result.push(attachment[Tools.memory]); + } + } + return result; + }, [attachments]); + + useLayoutEffect(() => { + if (showInfo !== prevShowInfoRef.current) { + prevShowInfoRef.current = showInfo; + setIsAnimating(true); + + if (showInfo && contentRef.current) { + requestAnimationFrame(() => { + if (contentRef.current) { + const height = contentRef.current.scrollHeight; + setContentHeight(height + 4); + } + }); + } else { + setContentHeight(0); + } + + const timer = setTimeout(() => { + setIsAnimating(false); + }, 400); + + return () => clearTimeout(timer); + } + }, [showInfo]); + + useEffect(() => { + if (!contentRef.current) { + return; + } + const resizeObserver = new ResizeObserver((entries) => { + if (showInfo && !isAnimating) { + for (const entry of entries) { + if (entry.target === contentRef.current) { + setContentHeight(entry.contentRect.height + 4); + } + } + } + }); + resizeObserver.observe(contentRef.current); + return () => { + resizeObserver.disconnect(); + }; + }, [showInfo, isAnimating]); + + if (!memoryArtifacts || memoryArtifacts.length === 0) { + return null; + } + + return ( + <> +
+
+ +
+
+
+
+
+ {showInfo && } +
+
+
+ + ); +} diff --git a/client/src/components/Chat/Messages/Content/MemoryInfo.tsx b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx new file mode 100644 index 0000000000..574c2e8f5f --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx @@ -0,0 +1,61 @@ +import type { MemoryArtifact } from 'librechat-data-provider'; +import { useLocalize } from '~/hooks'; + +export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) { + const localize = useLocalize(); + if (memoryArtifacts.length === 0) { + return null; + } + + // Group artifacts by type + const updatedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'update'); + const deletedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'delete'); + + if (updatedMemories.length === 0 && deletedMemories.length === 0) { + return null; + } + + return ( +
+ {updatedMemories.length > 0 && ( +
+

+ {localize('com_ui_memory_updated_items')} +

+
+ {updatedMemories.map((artifact, index) => ( +
+
+ {artifact.key} +
+
+ {artifact.value} +
+
+ ))} +
+
+ )} + + {deletedMemories.length > 0 && ( +
+

+ {localize('com_ui_memory_deleted_items')} +

+
+ {deletedMemories.map((artifact, index) => ( +
+
+ {artifact.key} +
+
+ {localize('com_ui_memory_deleted')} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index 7bc1a8e75f..23d7a456b9 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -5,9 +5,27 @@ import { SettingsTabValues } from 'librechat-data-provider'; import { useGetStartupConfig } from '~/data-provider'; import type { TDialogProps } from '~/common'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg'; -import { General, Chat, Speech, Beta, Commands, Data, Account, Balance } from './SettingsTabs'; +import { + GearIcon, + DataIcon, + SpeechIcon, + UserIcon, + ExperimentIcon, + PersonalizationIcon, +} from '~/components/svg'; +import { + General, + Chat, + Speech, + Beta, + Commands, + Data, + Account, + Balance, + Personalization, +} from './SettingsTabs'; import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks'; +import usePersonalizationAccess from '~/hooks/usePersonalizationAccess'; import { cn } from '~/utils'; export default function Settings({ open, onOpenChange }: TDialogProps) { @@ -16,6 +34,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { const localize = useLocalize(); const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL); const tabRefs = useRef({}); + const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess(); const handleKeyDown = (event: React.KeyboardEvent) => { const tabs: SettingsTabValues[] = [ @@ -24,6 +43,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { SettingsTabValues.BETA, SettingsTabValues.COMMANDS, SettingsTabValues.SPEECH, + ...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []), SettingsTabValues.DATA, ...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []), SettingsTabValues.ACCOUNT, @@ -80,6 +100,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { icon: , label: 'com_nav_setting_speech', }, + ...(hasAnyPersonalizationFeature + ? [ + { + value: SettingsTabValues.PERSONALIZATION, + icon: , + label: 'com_nav_setting_personalization' as TranslationKeys, + }, + ] + : []), { value: SettingsTabValues.DATA, icon: , @@ -87,11 +116,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { }, ...(startupConfig?.balance?.enabled ? [ - { - value: SettingsTabValues.BALANCE, + { + value: SettingsTabValues.BALANCE, icon: , - label: 'com_nav_setting_balance' as TranslationKeys, - }, + label: 'com_nav_setting_balance' as TranslationKeys, + }, ] : ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])), { @@ -213,6 +242,14 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { + {hasAnyPersonalizationFeature && ( + + + + )} diff --git a/client/src/components/Nav/SettingsTabs/Personalization.tsx b/client/src/components/Nav/SettingsTabs/Personalization.tsx new file mode 100644 index 0000000000..929646a440 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Personalization.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react'; +import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider'; +import { useToastContext } from '~/Providers'; +import { Switch } from '~/components/ui'; +import { useLocalize } from '~/hooks'; + +interface PersonalizationProps { + hasMemoryOptOut: boolean; + hasAnyPersonalizationFeature: boolean; +} + +export default function Personalization({ + hasMemoryOptOut, + hasAnyPersonalizationFeature, +}: PersonalizationProps) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { data: user } = useGetUserQuery(); + const [referenceSavedMemories, setReferenceSavedMemories] = useState(true); + + const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({ + onSuccess: () => { + showToast({ + message: localize('com_ui_preferences_updated'), + status: 'success', + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_error_updating_preferences'), + status: 'error', + }); + // Revert the toggle on error + setReferenceSavedMemories((prev) => !prev); + }, + }); + + // Initialize state from user data + useEffect(() => { + if (user?.personalization?.memories !== undefined) { + setReferenceSavedMemories(user.personalization.memories); + } + }, [user?.personalization?.memories]); + + const handleMemoryToggle = (checked: boolean) => { + setReferenceSavedMemories(checked); + updateMemoryPreferencesMutation.mutate({ memories: checked }); + }; + + if (!hasAnyPersonalizationFeature) { + return ( +
+
{localize('com_ui_no_personalization_available')}
+
+ ); + } + + return ( +
+ {/* Memory Settings Section */} + {hasMemoryOptOut && ( + <> +
+
{localize('com_ui_memory')}
+
+ +
+
+
+ {localize('com_ui_reference_saved_memories')} +
+
+ {localize('com_ui_reference_saved_memories_description')} +
+
+ +
+ + )} +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 380d9a7a6d..b3398431f5 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -7,3 +7,4 @@ export { RevokeKeysButton } from './Data/RevokeKeysButton'; export { default as Account } from './Account/Account'; export { default as Balance } from './Balance/Balance'; export { default as Speech } from './Speech/Speech'; +export { default as Personalization } from './Personalization'; diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 0d0ab9a2fc..19d2cde1f9 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -44,11 +44,11 @@ export default function FilterPrompts({ const categoryOptions = categories ? [...categories] : [ - { - value: SystemCategories.NO_CATEGORY, - label: localize('com_ui_no_category'), - }, - ]; + { + value: SystemCategories.NO_CATEGORY, + label: localize('com_ui_no_category'), + }, + ]; return [...baseOptions, ...categoryOptions]; }, [categories, localize]); diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/Groups/GroupSidePanel.tsx index 5cfd77ec2a..e359d13ea0 100644 --- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx +++ b/client/src/components/Prompts/Groups/GroupSidePanel.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation'; +import ManagePrompts from '~/components/Prompts/ManagePrompts'; import { useMediaQuery, usePromptGroupsNav } from '~/hooks'; import List from '~/components/Prompts/Groups/List'; import { cn } from '~/utils'; @@ -38,14 +39,17 @@ export default function GroupSidePanel({
- +
+ {isChatRoute && } + +
); } diff --git a/client/src/components/Prompts/Groups/PanelNavigation.tsx b/client/src/components/Prompts/Groups/PanelNavigation.tsx index 76395e842d..5600415d7e 100644 --- a/client/src/components/Prompts/Groups/PanelNavigation.tsx +++ b/client/src/components/Prompts/Groups/PanelNavigation.tsx @@ -19,11 +19,13 @@ function PanelNavigation({ }) { const localize = useLocalize(); return ( -
-
- {!isChatRoute && } -
-
+ <> +
{!isChatRoute && }
+
@@ -36,7 +38,7 @@ function PanelNavigation({ {localize('com_ui_next')}
-
+ ); } diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx index 444646c5ee..6bc84005c4 100644 --- a/client/src/components/Prompts/PromptsAccordion.tsx +++ b/client/src/components/Prompts/PromptsAccordion.tsx @@ -1,19 +1,17 @@ import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel'; import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt'; import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; -import ManagePrompts from '~/components/Prompts/ManagePrompts'; import { usePromptGroupsNav } from '~/hooks'; export default function PromptsAccordion() { const groupsNav = usePromptGroupsNav(); return (
- -
- + + +
-
); diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx index 12de8b452d..0506b3b0dd 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx @@ -80,13 +80,13 @@ const BookmarkTable = () => { -
{localize('com_ui_bookmarks_title')}
+
{localize('com_ui_bookmarks_title')}
-
{localize('com_ui_bookmarks_count')}
+
{localize('com_ui_bookmarks_count')}
-
{localize('com_assistants_actions')}
+
{localize('com_assistants_actions')}
diff --git a/client/src/components/SidePanel/Memories/AdminSettings.tsx b/client/src/components/SidePanel/Memories/AdminSettings.tsx new file mode 100644 index 0000000000..fcb347228d --- /dev/null +++ b/client/src/components/SidePanel/Memories/AdminSettings.tsx @@ -0,0 +1,212 @@ +import * as Ariakit from '@ariakit/react'; +import { useMemo, useEffect, useState } from 'react'; +import { ShieldEllipsis } from 'lucide-react'; +import { useForm, Controller } from 'react-hook-form'; +import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider'; +import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form'; +import { OGDialog, OGDialogTitle, OGDialogContent, OGDialogTrigger } from '~/components/ui'; +import { useUpdateMemoryPermissionsMutation } from '~/data-provider'; +import { Button, Switch, DropdownPopup } from '~/components/ui'; +import { useLocalize, useAuthContext } from '~/hooks'; +import { useToastContext } from '~/Providers'; + +type FormValues = Record; + +type LabelControllerProps = { + label: string; + memoryPerm: Permissions; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; +}; + +const LabelController: React.FC = ({ control, memoryPerm, label }) => ( +
+ {label} + ( + + )} + /> +
+); + +const AdminSettings = () => { + const localize = useLocalize(); + const { user, roles } = useAuthContext(); + const { showToast } = useToastContext(); + const { mutate, isLoading } = useUpdateMemoryPermissionsMutation({ + onSuccess: () => { + showToast({ status: 'success', message: localize('com_ui_saved') }); + }, + onError: () => { + showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') }); + }, + }); + + const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(SystemRoles.USER); + + const defaultValues = useMemo(() => { + if (roles?.[selectedRole]?.permissions) { + return roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]; + } + return roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]; + }, [roles, selectedRole]); + + const { + reset, + control, + setValue, + getValues, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues, + }); + + useEffect(() => { + if (roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]) { + reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]); + } else { + reset(roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]); + } + }, [roles, selectedRole, reset]); + + if (user?.role !== SystemRoles.ADMIN) { + return null; + } + + const labelControllerData = [ + { + memoryPerm: Permissions.USE, + label: localize('com_ui_memories_allow_use'), + }, + { + memoryPerm: Permissions.CREATE, + label: localize('com_ui_memories_allow_create'), + }, + { + memoryPerm: Permissions.UPDATE, + label: localize('com_ui_memories_allow_update'), + }, + { + memoryPerm: Permissions.READ, + label: localize('com_ui_memories_allow_read'), + }, + { + memoryPerm: Permissions.OPT_OUT, + label: localize('com_ui_memories_allow_opt_out'), + }, + ]; + + const onSubmit = (data: FormValues) => { + mutate({ roleName: selectedRole, updates: data }); + }; + + const roleDropdownItems = [ + { + label: SystemRoles.USER, + onClick: () => { + setSelectedRole(SystemRoles.USER); + }, + }, + { + label: SystemRoles.ADMIN, + onClick: () => { + setSelectedRole(SystemRoles.ADMIN); + }, + }, + ]; + + return ( + + + + + + {`${localize('com_ui_admin_settings')} - ${localize( + 'com_ui_memories', + )}`} +
+ {/* Role selection dropdown */} +
+ {localize('com_ui_role_select')}: + + {selectedRole} + + } + items={roleDropdownItems} + itemClassName="items-center justify-center" + sameWidth={true} + /> +
+ {/* Permissions form */} +
+
+ {labelControllerData.map(({ memoryPerm, label }) => ( +
+ + {selectedRole === SystemRoles.ADMIN && memoryPerm === Permissions.USE && ( + <> +
+ {localize('com_ui_admin_access_warning')} + {'\n'} + + {localize('com_ui_more_info')} + +
+ + )} +
+ ))} +
+
+ +
+
+
+
+
+ ); +}; + +export default AdminSettings; diff --git a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx new file mode 100644 index 0000000000..1670ba6f60 --- /dev/null +++ b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { OGDialog, OGDialogTemplate, Button, Label, Input } from '~/components/ui'; +import { useCreateMemoryMutation } from '~/data-provider'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { Spinner } from '~/components/svg'; + +interface MemoryCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + triggerRef?: React.MutableRefObject; +} + +export default function MemoryCreateDialog({ + open, + onOpenChange, + children, + triggerRef, +}: MemoryCreateDialogProps) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + + const hasCreateAccess = useHasAccess({ + permissionType: PermissionTypes.MEMORIES, + permission: Permissions.CREATE, + }); + + const { mutate: createMemory, isLoading } = useCreateMemoryMutation({ + onSuccess: () => { + showToast({ + message: localize('com_ui_memory_created'), + status: 'success', + }); + onOpenChange(false); + setKey(''); + setValue(''); + setTimeout(() => { + triggerRef?.current?.focus(); + }, 0); + }, + onError: (error: Error) => { + let errorMessage = localize('com_ui_error'); + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as any; + if (axiosError.response?.data?.error) { + errorMessage = axiosError.response.data.error; + + // Check for duplicate key error + if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) { + errorMessage = localize('com_ui_memory_key_exists'); + } + } + } else if (error.message) { + errorMessage = error.message; + } + + showToast({ + message: errorMessage, + status: 'error', + }); + }, + }); + + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + + const handleSave = () => { + if (!hasCreateAccess) { + return; + } + + if (!key.trim() || !value.trim()) { + showToast({ + message: localize('com_ui_field_required'), + status: 'error', + }); + return; + } + + createMemory({ + key: key.trim(), + value: value.trim(), + }); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && e.ctrlKey && hasCreateAccess) { + handleSave(); + } + }; + + return ( + + {children} + +
+ + setKey(e.target.value)} + onKeyDown={handleKeyPress} + placeholder={localize('com_ui_enter_key')} + className="w-full" + /> +
+
+ +