From c7f2ee36c534c1a25adece754f04f7f2bec8495c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 2 Jun 2025 14:37:37 -0400 Subject: [PATCH 01/49] =?UTF-8?q?=F0=9F=94=84=20chore:=20Update=20mongoose?= =?UTF-8?q?=20model=20imports=20in=20delete-banner.js=20and=20reset-passwo?= =?UTF-8?q?rd.js=20(#7690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/delete-banner.js | 3 ++- config/reset-password.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/config/delete-banner.js b/config/delete-banner.js index efb89f3418..af1353ff39 100644 --- a/config/delete-banner.js +++ b/config/delete-banner.js @@ -1,7 +1,8 @@ const path = require('path'); +const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose')); +const { Banner } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); -const Banner = require('~/models/schema/banner'); const connect = require('./connect'); (async () => { diff --git a/config/reset-password.js b/config/reset-password.js index 0fbeb1c890..736f6c24a1 100644 --- a/config/reset-password.js +++ b/config/reset-password.js @@ -1,8 +1,9 @@ const path = require('path'); const bcrypt = require('bcryptjs'); const readline = require('readline'); +const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose')); +const { User } = require('@librechat/data-schemas').createModels(mongoose); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); -const User = require('../api/models/User'); const connect = require('./connect'); const rl = readline.createInterface({ From 8cade2120d8f6d6155524a6590b024b27643caa1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 2 Jun 2025 14:56:26 -0400 Subject: [PATCH 02/49] =?UTF-8?q?=F0=9F=8E=A8=20style:=20Reduce=20Transiti?= =?UTF-8?q?on=20Duration=20For=20Nav=20And=20Header=20from=20#7653=20(#769?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Chat/Header.tsx | 4 ++-- client/src/components/Nav/Nav.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 8a9bd80c23..93a265f4a2 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -39,7 +39,7 @@ export default function Header() {
diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index d425468ca6..a692274719 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -30,7 +30,7 @@ const NavMask = memo( id="mobile-nav-mask-toggle" role="button" tabIndex={0} - className={`nav-mask transition-opacity duration-500 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`} + className={`nav-mask transition-opacity duration-200 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`} onClick={toggleNavVisible} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -186,7 +186,7 @@ const Nav = memo(
diff --git a/package-lock.json b/package-lock.json index 2988897407..af0d6dbc9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@langchain/google-genai": "^0.2.9", "@langchain/google-vertexai": "^0.2.9", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.37", + "@librechat/agents": "^2.4.38", "@librechat/data-schemas": "*", "@node-saml/passport-saml": "^5.0.0", "@waylaidwanderer/fetch-event-source": "^3.0.1", @@ -1344,33 +1344,6 @@ "@langchain/core": ">=0.3.48 <0.4.0" } }, - "api/node_modules/@librechat/agents": { - "version": "2.4.37", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.37.tgz", - "integrity": "sha512-joKKuNh5qWHspH9X4ZQdjyz4IBH6DGtKA4BAPXAvZyPyvadNcwFPEPqnvVtI9vwVVniqJD+t+4TGoGTC2P+19A==", - "license": "MIT", - "dependencies": { - "@langchain/anthropic": "^0.3.21", - "@langchain/aws": "^0.1.10", - "@langchain/community": "^0.3.44", - "@langchain/core": "^0.3.57", - "@langchain/deepseek": "^0.0.1", - "@langchain/google-genai": "^0.2.9", - "@langchain/google-vertexai": "^0.2.9", - "@langchain/langgraph": "^0.2.73", - "@langchain/mistralai": "^0.2.0", - "@langchain/ollama": "^0.2.0", - "@langchain/openai": "^0.5.11", - "@langchain/xai": "^0.0.2", - "cheerio": "^1.0.0", - "dotenv": "^16.4.7", - "https-proxy-agent": "^7.0.6", - "nanoid": "^3.3.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, "api/node_modules/@smithy/abort-controller": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.2.tgz", @@ -18998,9 +18971,9 @@ } }, "node_modules/@langchain/langgraph": { - "version": "0.2.73", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.73.tgz", - "integrity": "sha512-vw+IXV2Q7x/QaykNj3VE/Ak3aPlst3spkpM6zYtqwGkQlhLZU4Lb8PHHPjqNNYHSdOTDj9x4jIRUPZArGHx9Aw==", + "version": "0.2.74", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.74.tgz", + "integrity": "sha512-oHpEi5sTZTPaeZX1UnzfM2OAJ21QGQrwReTV6+QnX7h8nDCBzhtipAw1cK616S+X8zpcVOjgOtJuaJhXa4mN8w==", "license": "MIT", "dependencies": { "@langchain/langgraph-checkpoint": "~0.0.17", @@ -19022,9 +18995,9 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.17.tgz", - "integrity": "sha512-6b3CuVVYx+7x0uWLG+7YXz9j2iBa+tn2AXvkLxzEvaAsLE6Sij++8PPbS2BZzC+S/FPJdWsz6I5bsrqL0BYrCA==", + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz", + "integrity": "sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ==", "license": "MIT", "dependencies": { "uuid": "^10.0.0" @@ -19050,9 +19023,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "0.0.78", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.78.tgz", - "integrity": "sha512-skkUDmEhClWzlsr8jRaS1VpXVBISm5OFd0MUtS1jKRL5pn08K+IJRvHnlzgum9x7Dste9KXGcIGVoR7cNKJQrw==", + "version": "0.0.82", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.82.tgz", + "integrity": "sha512-QxhGtDArHkqsJAbO5RuZsCyvDmPWf4pUpkOpLDzPEQXCBuasrBRgB6pxQWof2l6kfMYCfrc6lp3jL6TAqapmjQ==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -19271,6 +19244,612 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@librechat/agents": { + "version": "2.4.38", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.38.tgz", + "integrity": "sha512-GfQ36TpKrC60vesTM0MrBaE8aEC50yBp0CsT4ERHO9HbCjH8na+CA14Ldps1cGwinL4gtyDH2gB0nVuhgEtYAg==", + "license": "MIT", + "dependencies": { + "@langchain/anthropic": "^0.3.21", + "@langchain/aws": "^0.1.10", + "@langchain/community": "^0.3.44", + "@langchain/core": "^0.3.57", + "@langchain/deepseek": "^0.0.1", + "@langchain/google-genai": "^0.2.9", + "@langchain/google-vertexai": "^0.2.9", + "@langchain/langgraph": "^0.2.73", + "@langchain/mistralai": "^0.2.0", + "@langchain/ollama": "^0.2.0", + "@langchain/openai": "^0.5.11", + "@langchain/xai": "^0.0.2", + "cheerio": "^1.0.0", + "dotenv": "^16.4.7", + "https-proxy-agent": "^7.0.6", + "nanoid": "^3.3.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@librechat/agents/node_modules/@langchain/community": { + "version": "0.3.45", + "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.45.tgz", + "integrity": "sha512-KkAGmnP+w5tozLYsj/kGKwyfuPnCcA6MyDXfNF7oDo7L1TxhUgdEKhvNsY7ooLXz6Xh/LV5Kqp2B8U0jfYCQKQ==", + "license": "MIT", + "dependencies": { + "@langchain/openai": ">=0.2.0 <0.6.0", + "@langchain/weaviate": "^0.2.0", + "binary-extensions": "^2.2.0", + "expr-eval": "^2.0.2", + "flat": "^5.0.2", + "js-yaml": "^4.1.0", + "langchain": ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0", + "langsmith": "^0.3.29", + "uuid": "^10.0.0", + "zod": "^3.22.3", + "zod-to-json-schema": "^3.22.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@arcjet/redact": "^v1.0.0-alpha.23", + "@aws-crypto/sha256-js": "^5.0.0", + "@aws-sdk/client-bedrock-agent-runtime": "^3.749.0", + "@aws-sdk/client-bedrock-runtime": "^3.749.0", + "@aws-sdk/client-dynamodb": "^3.749.0", + "@aws-sdk/client-kendra": "^3.749.0", + "@aws-sdk/client-lambda": "^3.749.0", + "@aws-sdk/client-s3": "^3.749.0", + "@aws-sdk/client-sagemaker-runtime": "^3.749.0", + "@aws-sdk/client-sfn": "^3.749.0", + "@aws-sdk/credential-provider-node": "^3.388.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.15.0", + "@browserbasehq/sdk": "*", + "@browserbasehq/stagehand": "^1.0.0", + "@clickhouse/client": "^0.2.5", + "@cloudflare/ai": "*", + "@datastax/astra-db-ts": "^1.0.0", + "@elastic/elasticsearch": "^8.4.0", + "@getmetal/metal-sdk": "*", + "@getzep/zep-cloud": "^1.0.6", + "@getzep/zep-js": "^0.9.0", + "@gomomento/sdk": "^1.51.1", + "@gomomento/sdk-core": "^1.51.1", + "@google-ai/generativelanguage": "*", + "@google-cloud/storage": "^6.10.1 || ^7.7.0", + "@gradientai/nodejs-sdk": "^1.2.0", + "@huggingface/inference": "^2.6.4", + "@huggingface/transformers": "^3.2.3", + "@ibm-cloud/watsonx-ai": "*", + "@lancedb/lancedb": "^0.12.0", + "@langchain/core": ">=0.2.21 <0.4.0", + "@layerup/layerup-security": "^1.5.12", + "@libsql/client": "^0.14.0", + "@mendable/firecrawl-js": "^1.4.3", + "@mlc-ai/web-llm": "*", + "@mozilla/readability": "*", + "@neondatabase/serverless": "*", + "@notionhq/client": "^2.2.10", + "@opensearch-project/opensearch": "*", + "@pinecone-database/pinecone": "*", + "@planetscale/database": "^1.8.0", + "@premai/prem-sdk": "^0.3.25", + "@qdrant/js-client-rest": "^1.8.2", + "@raycast/api": "^1.55.2", + "@rockset/client": "^0.9.1", + "@smithy/eventstream-codec": "^2.0.5", + "@smithy/protocol-http": "^3.0.6", + "@smithy/signature-v4": "^2.0.10", + "@smithy/util-utf8": "^2.0.0", + "@spider-cloud/spider-client": "^0.0.21", + "@supabase/supabase-js": "^2.45.0", + "@tensorflow-models/universal-sentence-encoder": "*", + "@tensorflow/tfjs-converter": "*", + "@tensorflow/tfjs-core": "*", + "@upstash/ratelimit": "^1.1.3 || ^2.0.3", + "@upstash/redis": "^1.20.6", + "@upstash/vector": "^1.1.1", + "@vercel/kv": "*", + "@vercel/postgres": "*", + "@writerai/writer-sdk": "^0.40.2", + "@xata.io/client": "^0.28.0", + "@zilliz/milvus2-sdk-node": ">=2.3.5", + "apify-client": "^2.7.1", + "assemblyai": "^4.6.0", + "azion": "^1.11.1", + "better-sqlite3": ">=9.4.0 <12.0.0", + "cassandra-driver": "^4.7.2", + "cborg": "^4.1.1", + "cheerio": "^1.0.0-rc.12", + "chromadb": "*", + "closevector-common": "0.1.3", + "closevector-node": "0.1.6", + "closevector-web": "0.1.6", + "cohere-ai": "*", + "convex": "^1.3.1", + "crypto-js": "^4.2.0", + "d3-dsv": "^2.0.0", + "discord.js": "^14.14.1", + "dria": "^0.0.3", + "duck-duck-scrape": "^2.2.5", + "epub2": "^3.0.1", + "fast-xml-parser": "*", + "firebase-admin": "^11.9.0 || ^12.0.0", + "google-auth-library": "*", + "googleapis": "*", + "hnswlib-node": "^3.0.0", + "html-to-text": "^9.0.5", + "ibm-cloud-sdk-core": "*", + "ignore": "^5.2.0", + "interface-datastore": "^8.2.11", + "ioredis": "^5.3.2", + "it-all": "^3.0.4", + "jsdom": "*", + "jsonwebtoken": "^9.0.2", + "llmonitor": "^0.5.9", + "lodash": "^4.17.21", + "lunary": "^0.7.10", + "mammoth": "^1.6.0", + "mariadb": "^3.4.0", + "mem0ai": "^2.1.8", + "mongodb": ">=5.2.0", + "mysql2": "^3.9.8", + "neo4j-driver": "*", + "notion-to-md": "^3.1.0", + "officeparser": "^4.0.4", + "openai": "*", + "pdf-parse": "1.1.1", + "pg": "^8.11.0", + "pg-copy-streams": "^6.0.5", + "pickleparser": "^0.2.1", + "playwright": "^1.32.1", + "portkey-ai": "^0.1.11", + "puppeteer": "*", + "pyodide": ">=0.24.1 <0.27.0", + "redis": "*", + "replicate": "*", + "sonix-speech-recognition": "^2.1.1", + "srt-parser-2": "^1.2.3", + "typeorm": "^0.3.20", + "typesense": "^1.5.3", + "usearch": "^1.1.1", + "voy-search": "0.6.2", + "weaviate-client": "^3.5.2", + "web-auth-library": "^1.0.3", + "word-extractor": "*", + "ws": "^8.14.2", + "youtubei.js": "*" + }, + "peerDependenciesMeta": { + "@arcjet/redact": { + "optional": true + }, + "@aws-crypto/sha256-js": { + "optional": true + }, + "@aws-sdk/client-bedrock-agent-runtime": { + "optional": true + }, + "@aws-sdk/client-bedrock-runtime": { + "optional": true + }, + "@aws-sdk/client-dynamodb": { + "optional": true + }, + "@aws-sdk/client-kendra": { + "optional": true + }, + "@aws-sdk/client-lambda": { + "optional": true + }, + "@aws-sdk/client-s3": { + "optional": true + }, + "@aws-sdk/client-sagemaker-runtime": { + "optional": true + }, + "@aws-sdk/client-sfn": { + "optional": true + }, + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@aws-sdk/dsql-signer": { + "optional": true + }, + "@azure/search-documents": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@browserbasehq/sdk": { + "optional": true + }, + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/ai": { + "optional": true + }, + "@datastax/astra-db-ts": { + "optional": true + }, + "@elastic/elasticsearch": { + "optional": true + }, + "@getmetal/metal-sdk": { + "optional": true + }, + "@getzep/zep-cloud": { + "optional": true + }, + "@getzep/zep-js": { + "optional": true + }, + "@gomomento/sdk": { + "optional": true + }, + "@gomomento/sdk-core": { + "optional": true + }, + "@google-ai/generativelanguage": { + "optional": true + }, + "@google-cloud/storage": { + "optional": true + }, + "@gradientai/nodejs-sdk": { + "optional": true + }, + "@huggingface/inference": { + "optional": true + }, + "@huggingface/transformers": { + "optional": true + }, + "@lancedb/lancedb": { + "optional": true + }, + "@layerup/layerup-security": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@mendable/firecrawl-js": { + "optional": true + }, + "@mlc-ai/web-llm": { + "optional": true + }, + "@mozilla/readability": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@notionhq/client": { + "optional": true + }, + "@opensearch-project/opensearch": { + "optional": true + }, + "@pinecone-database/pinecone": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@premai/prem-sdk": { + "optional": true + }, + "@qdrant/js-client-rest": { + "optional": true + }, + "@raycast/api": { + "optional": true + }, + "@rockset/client": { + "optional": true + }, + "@smithy/eventstream-codec": { + "optional": true + }, + "@smithy/protocol-http": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "@smithy/util-utf8": { + "optional": true + }, + "@spider-cloud/spider-client": { + "optional": true + }, + "@supabase/supabase-js": { + "optional": true + }, + "@tensorflow-models/universal-sentence-encoder": { + "optional": true + }, + "@tensorflow/tfjs-converter": { + "optional": true + }, + "@tensorflow/tfjs-core": { + "optional": true + }, + "@upstash/ratelimit": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@upstash/vector": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@writerai/writer-sdk": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "@zilliz/milvus2-sdk-node": { + "optional": true + }, + "apify-client": { + "optional": true + }, + "assemblyai": { + "optional": true + }, + "azion": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "cassandra-driver": { + "optional": true + }, + "cborg": { + "optional": true + }, + "cheerio": { + "optional": true + }, + "chromadb": { + "optional": true + }, + "closevector-common": { + "optional": true + }, + "closevector-node": { + "optional": true + }, + "closevector-web": { + "optional": true + }, + "cohere-ai": { + "optional": true + }, + "convex": { + "optional": true + }, + "crypto-js": { + "optional": true + }, + "d3-dsv": { + "optional": true + }, + "discord.js": { + "optional": true + }, + "dria": { + "optional": true + }, + "duck-duck-scrape": { + "optional": true + }, + "epub2": { + "optional": true + }, + "fast-xml-parser": { + "optional": true + }, + "firebase-admin": { + "optional": true + }, + "google-auth-library": { + "optional": true + }, + "googleapis": { + "optional": true + }, + "hnswlib-node": { + "optional": true + }, + "html-to-text": { + "optional": true + }, + "ignore": { + "optional": true + }, + "interface-datastore": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "it-all": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "llmonitor": { + "optional": true + }, + "lodash": { + "optional": true + }, + "lunary": { + "optional": true + }, + "mammoth": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mem0ai": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "neo4j-driver": { + "optional": true + }, + "notion-to-md": { + "optional": true + }, + "officeparser": { + "optional": true + }, + "pdf-parse": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-copy-streams": { + "optional": true + }, + "pickleparser": { + "optional": true + }, + "playwright": { + "optional": true + }, + "portkey-ai": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "pyodide": { + "optional": true + }, + "redis": { + "optional": true + }, + "replicate": { + "optional": true + }, + "sonix-speech-recognition": { + "optional": true + }, + "srt-parser-2": { + "optional": true + }, + "typeorm": { + "optional": true + }, + "typesense": { + "optional": true + }, + "usearch": { + "optional": true + }, + "voy-search": { + "optional": true + }, + "weaviate-client": { + "optional": true + }, + "web-auth-library": { + "optional": true + }, + "word-extractor": { + "optional": true + }, + "ws": { + "optional": true + }, + "youtubei.js": { + "optional": true + } + } + }, + "node_modules/@librechat/agents/node_modules/@langchain/openai": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.11.tgz", + "integrity": "sha512-DAp7x+NfjSqDvKVMle8yb85nzz+3ctP7zGJaeRS0vLmvkY9qf/jRkowsM0mcsIiEUKhG/AHzWqvxbhktb/jJ6Q==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^4.96.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.48 <0.4.0" + } + }, + "node_modules/@librechat/agents/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@librechat/agents/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@librechat/agents/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@librechat/backend": { "resolved": "api", "link": true From be4cf5846c569938f91ac01fc282cbc85aebbf37 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 4 Jun 2025 13:12:37 -0400 Subject: [PATCH 09/49] =?UTF-8?q?=F0=9F=93=A7=20feat:=20Mailgun=20API=20Em?= =?UTF-8?q?ail=20Configuration=20(#7742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add undefined password check in local user authentication * fix: edge case - issue deleting user when no conversations in deleteUserController * feat: Integrate Mailgun API for email sending functionality * fix: undefined SESSION_EXPIRY handling and add tests * fix: update import path for isEnabled utility in azureUtils.js to resolve circular dep. --- .env.example | 12 ++ api/models/userMethods.js | 4 + api/server/controllers/UserController.js | 6 +- api/server/utils/index.js | 13 +- api/server/utils/sendEmail.js | 118 ++++++++++--- api/strategies/localStrategy.js | 6 + api/utils/azureUtils.js | 2 +- packages/data-schemas/jest.config.mjs | 3 +- .../data-schemas/src/methods/user.test.ts | 163 ++++++++++++++++++ packages/data-schemas/src/methods/user.ts | 13 +- 10 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 packages/data-schemas/src/methods/user.test.ts diff --git a/.env.example b/.env.example index f79b89a155..876535b345 100644 --- a/.env.example +++ b/.env.example @@ -515,6 +515,18 @@ EMAIL_PASSWORD= EMAIL_FROM_NAME= EMAIL_FROM=noreply@librechat.ai +#========================# +# Mailgun API # +#========================# + +# MAILGUN_API_KEY=your-mailgun-api-key +# MAILGUN_DOMAIN=mg.yourdomain.com +# EMAIL_FROM=noreply@yourdomain.com +# EMAIL_FROM_NAME="LibreChat" + +# # Optional: For EU region +# MAILGUN_HOST=https://api.eu.mailgun.net + #========================# # Firebase CDN # #========================# diff --git a/api/models/userMethods.js b/api/models/userMethods.js index e8bf5e4784..a36409ebcf 100644 --- a/api/models/userMethods.js +++ b/api/models/userMethods.js @@ -12,6 +12,10 @@ const comparePassword = async (user, candidatePassword) => { throw new Error('No user provided'); } + if (!user.password) { + throw new Error('No password, likely an email first registered via Social/OIDC login'); + } + return new Promise((resolve, reject) => { bcrypt.compare(candidatePassword, user.password, (err, isMatch) => { if (err) { diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index a2fbc3c485..4577d20159 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -163,7 +163,11 @@ const deleteUserController = async (req, res) => { await Balance.deleteMany({ user: user._id }); // delete user balances await deletePresets(user.id); // delete user presets /* TODO: Delete Assistant Threads */ - await deleteConvos(user.id); // delete user convos + try { + await deleteConvos(user.id); // delete user convos + } catch (error) { + logger.error('[deleteUserController] Error deleting user convos, likely no convos', error); + } await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth await deleteUserById(user.id); // delete user await deleteAllSharedLinks(user.id); // delete user shared links diff --git a/api/server/utils/index.js b/api/server/utils/index.js index b79b42f00d..aa432ec379 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -13,12 +13,19 @@ const math = require('./math'); * @returns {Boolean} */ function checkEmailConfig() { - return ( + // Check if Mailgun is configured + const hasMailgunConfig = + !!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM; + + // Check if SMTP is configured + const hasSMTPConfig = (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && !!process.env.EMAIL_USERNAME && !!process.env.EMAIL_PASSWORD && - !!process.env.EMAIL_FROM - ); + !!process.env.EMAIL_FROM; + + // Return true if either Mailgun or SMTP is properly configured + return hasMailgunConfig || hasSMTPConfig; } module.exports = { diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js index 59d75830f4..42f99c78fc 100644 --- a/api/server/utils/sendEmail.js +++ b/api/server/utils/sendEmail.js @@ -1,10 +1,69 @@ const fs = require('fs'); const path = require('path'); +const axios = require('axios'); +const FormData = require('form-data'); const nodemailer = require('nodemailer'); const handlebars = require('handlebars'); const { isEnabled } = require('~/server/utils/handleText'); +const { logAxiosError } = require('~/utils'); const logger = require('~/config/winston'); +/** + * Sends an email using Mailgun API. + * + * @async + * @function sendEmailViaMailgun + * @param {Object} params - The parameters for sending the email. + * @param {string} params.to - The recipient's email address. + * @param {string} params.from - The sender's email address. + * @param {string} params.subject - The subject of the email. + * @param {string} params.html - The HTML content of the email. + * @returns {Promise} - A promise that resolves to the response from Mailgun API. + */ +const sendEmailViaMailgun = async ({ to, from, subject, html }) => { + const mailgunApiKey = process.env.MAILGUN_API_KEY; + const mailgunDomain = process.env.MAILGUN_DOMAIN; + const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net'; + + if (!mailgunApiKey || !mailgunDomain) { + throw new Error('Mailgun API key and domain are required'); + } + + const formData = new FormData(); + formData.append('from', from); + formData.append('to', to); + formData.append('subject', subject); + formData.append('html', html); + + try { + const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, { + headers: { + ...formData.getHeaders(), + Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`, + }, + }); + + return response.data; + } catch (error) { + throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' })); + } +}; + +/** + * Sends an email using SMTP via Nodemailer. + * + * @async + * @function sendEmailViaSMTP + * @param {Object} params - The parameters for sending the email. + * @param {Object} params.transporterOptions - The transporter configuration options. + * @param {Object} params.mailOptions - The email options. + * @returns {Promise} - A promise that resolves to the info object of the sent email. + */ +const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => { + const transporter = nodemailer.createTransport(transporterOptions); + return await transporter.sendMail(mailOptions); +}; + /** * Sends an email using the specified template, subject, and payload. * @@ -34,6 +93,30 @@ const logger = require('~/config/winston'); */ const sendEmail = async ({ email, subject, payload, template, throwError = true }) => { try { + // Read and compile the email template + const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8'); + const compiledTemplate = handlebars.compile(source); + const html = compiledTemplate(payload); + + // Prepare common email data + const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE; + const fromEmail = process.env.EMAIL_FROM; + const fromAddress = `"${fromName}" <${fromEmail}>`; + const toAddress = `"${payload.name}" <${email}>`; + + // Check if Mailgun is configured + if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) { + logger.debug('[sendEmail] Using Mailgun provider'); + return await sendEmailViaMailgun({ + from: fromAddress, + to: toAddress, + subject: subject, + html: html, + }); + } + + // Default to SMTP + logger.debug('[sendEmail] Using SMTP provider'); const transporterOptions = { // Use STARTTLS by default instead of obligatory TLS secure: process.env.EMAIL_ENCRYPTION === 'tls', @@ -62,30 +145,21 @@ const sendEmail = async ({ email, subject, payload, template, throwError = true transporterOptions.port = process.env.EMAIL_PORT ?? 25; } - const transporter = nodemailer.createTransport(transporterOptions); - - const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8'); - const compiledTemplate = handlebars.compile(source); - const options = () => { - return { - // Header address should contain name-addr - from: - `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}"` + - `<${process.env.EMAIL_FROM}>`, - to: `"${payload.name}" <${email}>`, - envelope: { - // Envelope from should contain addr-spec - // Mistake in the Nodemailer documentation? - from: process.env.EMAIL_FROM, - to: email, - }, - subject: subject, - html: compiledTemplate(payload), - }; + const mailOptions = { + // Header address should contain name-addr + from: fromAddress, + to: toAddress, + envelope: { + // Envelope from should contain addr-spec + // Mistake in the Nodemailer documentation? + from: fromEmail, + to: email, + }, + subject: subject, + html: html, }; - // Send email - return await transporter.sendMail(options()); + return await sendEmailViaSMTP({ transporterOptions, mailOptions }); } catch (error) { if (throwError) { throw error; diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js index edc749ee9e..bc84e7c6b5 100644 --- a/api/strategies/localStrategy.js +++ b/api/strategies/localStrategy.js @@ -29,6 +29,12 @@ async function passportLogin(req, email, password, done) { return done(null, false, { message: 'Email does not exist.' }); } + if (!user.password) { + logError('Passport Local Strategy - User has no password', { email }); + logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`); + return done(null, false, { message: 'Email does not exist.' }); + } + const isMatch = await comparePassword(user, password); if (!isMatch) { logError('Passport Local Strategy - Password does not match', { isMatch }); diff --git a/api/utils/azureUtils.js b/api/utils/azureUtils.js index 27396a8fc5..7adb133449 100644 --- a/api/utils/azureUtils.js +++ b/api/utils/azureUtils.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('~/server/utils/handleText'); /** * Sanitizes the model name to be used in the URL by removing or replacing disallowed characters. diff --git a/packages/data-schemas/jest.config.mjs b/packages/data-schemas/jest.config.mjs index f5fb1f20d7..b1fae43705 100644 --- a/packages/data-schemas/jest.config.mjs +++ b/packages/data-schemas/jest.config.mjs @@ -5,6 +5,7 @@ export default { testResultsProcessor: 'jest-junit', moduleNameMapper: { '^@src/(.*)$': '/src/$1', + '^~/(.*)$': '/src/$1', }, // coverageThreshold: { // global: { @@ -16,4 +17,4 @@ export default { // }, restoreMocks: true, testTimeout: 15000, -}; \ No newline at end of file +}; diff --git a/packages/data-schemas/src/methods/user.test.ts b/packages/data-schemas/src/methods/user.test.ts new file mode 100644 index 0000000000..6dafd4e8fa --- /dev/null +++ b/packages/data-schemas/src/methods/user.test.ts @@ -0,0 +1,163 @@ +import mongoose from 'mongoose'; +import { createUserMethods } from './user'; +import { signPayload } from '~/crypto'; +import type { IUser } from '~/types'; + +jest.mock('~/crypto', () => ({ + signPayload: jest.fn(), +})); + +describe('User Methods', () => { + const mockSignPayload = signPayload as jest.MockedFunction; + let userMethods: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + userMethods = createUserMethods(mongoose); + }); + + describe('generateToken', () => { + const mockUser = { + _id: 'user123', + username: 'testuser', + provider: 'local', + email: 'test@example.com', + name: 'Test User', + avatar: '', + role: 'user', + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + } as IUser; + + afterEach(() => { + delete process.env.SESSION_EXPIRY; + delete process.env.JWT_SECRET; + }); + + it('should default to 15 minutes when SESSION_EXPIRY is not set', async () => { + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds + }); + }); + + it('should default to 15 minutes when SESSION_EXPIRY is empty string', async () => { + process.env.SESSION_EXPIRY = ''; + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds + }); + }); + + it('should use custom expiry when SESSION_EXPIRY is set to a valid expression', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 30'; // 30 minutes + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 1800, // 30 minutes in seconds + }); + }); + + it('should default to 15 minutes when SESSION_EXPIRY evaluates to falsy value', async () => { + process.env.SESSION_EXPIRY = '0'; // This will evaluate to 0, which is falsy + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds + }); + }); + + it('should throw error when no user is provided', async () => { + process.env.JWT_SECRET = 'test-secret'; + + await expect(userMethods.generateToken(null as unknown as IUser)).rejects.toThrow( + 'No user provided', + ); + }); + + it('should return the token from signPayload', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 60'; // 1 hour + process.env.JWT_SECRET = 'test-secret'; + const expectedToken = 'generated-jwt-token'; + mockSignPayload.mockResolvedValue(expectedToken); + + const token = await userMethods.generateToken(mockUser); + + expect(token).toBe(expectedToken); + }); + + it('should handle invalid SESSION_EXPIRY expressions gracefully', async () => { + process.env.SESSION_EXPIRY = 'invalid expression'; + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + // Mock console.warn to verify it's called + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await userMethods.generateToken(mockUser); + + // Should use default value when eval fails + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds (default) + }); + + // Verify warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Invalid SESSION_EXPIRY expression, using default:', + expect.any(SyntaxError), + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 5c7b2e40d8..b3460faa72 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -145,7 +145,18 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { throw new Error('No user provided'); } - const expires = eval(process.env.SESSION_EXPIRY ?? '0') ?? 1000 * 60 * 15; + let expires = 1000 * 60 * 15; + + if (process.env.SESSION_EXPIRY !== undefined && process.env.SESSION_EXPIRY !== '') { + try { + const evaluated = eval(process.env.SESSION_EXPIRY); + if (evaluated) { + expires = evaluated; + } + } catch (error) { + console.warn('Invalid SESSION_EXPIRY expression, using default:', error); + } + } return await signPayload({ payload: { From dff4fcac00939cf5a9b2077eba4cadcfe47c72eb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 4 Jun 2025 23:11:34 -0400 Subject: [PATCH 10/49] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Apply=20Mongoose=20?= =?UTF-8?q?Plugin=20at=20Model=20Creation=20(#7749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply mongoMeili when models are created to use main runtime mongoose * chore: update @librechat/data-schemas version to 0.0.8 * refactor: remove unused useDebounceCodeBlock * fix: ensure setter function is stable and handle numeric conversion in useDebouncedInput * refactor: replace useCallback with useMemo for stable debounced function in useDebouncedInput --- .../Artifacts/useDebounceCodeBlock.ts | 37 ------------------- .../hooks/Conversations/useDebouncedInput.ts | 15 ++++---- package-lock.json | 4 +- packages/data-schemas/package.json | 2 +- packages/data-schemas/src/models/convo.ts | 11 ++++++ packages/data-schemas/src/models/message.ts | 13 ++++++- .../src/models/plugins/mongoMeili.ts | 10 +++-- packages/data-schemas/src/schema/convo.ts | 11 ------ packages/data-schemas/src/schema/message.ts | 10 ----- 9 files changed, 40 insertions(+), 73 deletions(-) delete mode 100644 client/src/components/Artifacts/useDebounceCodeBlock.ts diff --git a/client/src/components/Artifacts/useDebounceCodeBlock.ts b/client/src/components/Artifacts/useDebounceCodeBlock.ts deleted file mode 100644 index 27aaf5bc83..0000000000 --- a/client/src/components/Artifacts/useDebounceCodeBlock.ts +++ /dev/null @@ -1,37 +0,0 @@ -// client/src/hooks/useDebounceCodeBlock.ts -import { useCallback, useEffect } from 'react'; -import debounce from 'lodash/debounce'; -import { useSetRecoilState } from 'recoil'; -import { codeBlocksState, codeBlockIdsState } from '~/store/artifacts'; -import type { CodeBlock } from '~/common'; - -export function useDebounceCodeBlock() { - const setCodeBlocks = useSetRecoilState(codeBlocksState); - const setCodeBlockIds = useSetRecoilState(codeBlockIdsState); - - const updateCodeBlock = useCallback((codeBlock: CodeBlock) => { - console.log('Updating code block:', codeBlock); - setCodeBlocks((prev) => ({ - ...prev, - [codeBlock.id]: codeBlock, - })); - setCodeBlockIds((prev) => - prev.includes(codeBlock.id) ? prev : [...prev, codeBlock.id], - ); - }, [setCodeBlocks, setCodeBlockIds]); - - const debouncedUpdateCodeBlock = useCallback( - debounce((codeBlock: CodeBlock) => { - updateCodeBlock(codeBlock); - }, 25), - [updateCodeBlock], - ); - - useEffect(() => { - return () => { - debouncedUpdateCodeBlock.cancel(); - }; - }, [debouncedUpdateCodeBlock]); - - return debouncedUpdateCodeBlock; -} diff --git a/client/src/hooks/Conversations/useDebouncedInput.ts b/client/src/hooks/Conversations/useDebouncedInput.ts index 56584769ce..73fbb66723 100644 --- a/client/src/hooks/Conversations/useDebouncedInput.ts +++ b/client/src/hooks/Conversations/useDebouncedInput.ts @@ -1,5 +1,5 @@ import debounce from 'lodash/debounce'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import type { SetterOrUpdater } from 'recoil'; import type { TSetOption } from '~/common'; import { defaultDebouncedDelay } from '~/common'; @@ -29,10 +29,10 @@ function useDebouncedInput({ /** A debounced function to call the passed setOption with the optionKey and new value. * - Note: We use useCallback to ensure our debounced function is stable across renders. */ - const setDebouncedOption = useCallback( - debounce(setOption && optionKey ? setOption(optionKey) : setter, delay), - [], + Note: We use useMemo to ensure our debounced function is stable across renders and properly typed. */ + const setDebouncedOption = useMemo( + () => debounce(setOption && optionKey ? setOption(optionKey) : setter || (() => {}), delay), + [setOption, optionKey, setter, delay], ); /** An onChange handler that updates the local state and the debounced option */ @@ -42,8 +42,9 @@ function useDebouncedInput({ typeof e !== 'object' ? e : ((e as React.ChangeEvent).target - .value as unknown as T); - if (numeric === true) { + .value as unknown as T); + // Handle numeric conversion only if value is not undefined and not empty string + if (numeric === true && newValue !== undefined && newValue !== '') { newValue = Number(newValue) as unknown as T; } setValue(newValue); diff --git a/package-lock.json b/package-lock.json index af0d6dbc9d..f53d7389ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46018,7 +46018,7 @@ "passport-facebook": "^3.0.0" }, "devDependencies": { - "@librechat/data-schemas": "^0.0.7", + "@librechat/data-schemas": "^0.0.8", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", "@rollup/plugin-json": "^6.1.0", @@ -46186,7 +46186,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.7", + "version": "0.0.8", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 9c2dddf0b5..8d625fa835 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.7", + "version": "0.0.8", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", diff --git a/packages/data-schemas/src/models/convo.ts b/packages/data-schemas/src/models/convo.ts index 13a36e2069..da0a8c68cf 100644 --- a/packages/data-schemas/src/models/convo.ts +++ b/packages/data-schemas/src/models/convo.ts @@ -1,10 +1,21 @@ import type * as t from '~/types'; +import mongoMeili from '~/models/plugins/mongoMeili'; import convoSchema from '~/schema/convo'; /** * Creates or returns the Conversation model using the provided mongoose instance and schema */ export function createConversationModel(mongoose: typeof import('mongoose')) { + if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { + convoSchema.plugin(mongoMeili, { + mongoose, + host: process.env.MEILI_HOST, + apiKey: process.env.MEILI_MASTER_KEY, + /** Note: Will get created automatically if it doesn't exist already */ + indexName: 'convos', + primaryKey: 'conversationId', + }); + } return ( mongoose.models.Conversation || mongoose.model('Conversation', convoSchema) ); diff --git a/packages/data-schemas/src/models/message.ts b/packages/data-schemas/src/models/message.ts index cb5bd9e7d3..3a81211e68 100644 --- a/packages/data-schemas/src/models/message.ts +++ b/packages/data-schemas/src/models/message.ts @@ -1,9 +1,20 @@ -import messageSchema from '~/schema/message'; import type * as t from '~/types'; +import mongoMeili from '~/models/plugins/mongoMeili'; +import messageSchema from '~/schema/message'; /** * Creates or returns the Message model using the provided mongoose instance and schema */ export function createMessageModel(mongoose: typeof import('mongoose')) { + if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { + messageSchema.plugin(mongoMeili, { + mongoose, + host: process.env.MEILI_HOST, + apiKey: process.env.MEILI_MASTER_KEY, + indexName: 'messages', + primaryKey: 'messageId', + }); + } + return mongoose.models.Message || mongoose.model('Message', messageSchema); } diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 111f9e0bba..1956be9f30 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import { MeiliSearch, Index } from 'meilisearch'; -import mongoose, { Schema, Document, Model, Query } from 'mongoose'; +import type { FilterQuery, Types, Schema, Document, Model, Query } from 'mongoose'; import logger from '~/config/meiliLogger'; interface MongoMeiliOptions { @@ -8,6 +8,7 @@ interface MongoMeiliOptions { apiKey: string; indexName: string; primaryKey: string; + mongoose: typeof import('mongoose'); } interface MeiliIndexable { @@ -314,7 +315,7 @@ const createMeiliMongooseModel = ({ } await this.collection.updateMany( - { _id: this._id as mongoose.Types.ObjectId }, + { _id: this._id as Types.ObjectId }, { $set: { _meiliIndex: true } }, ); } @@ -398,6 +399,7 @@ const createMeiliMongooseModel = ({ * @param options.primaryKey - The primary key field for indexing. */ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions): void { + const mongoose = options.mongoose; validateOptions(options); // Add _meiliIndex field to the schema to track if a document has been indexed in MeiliSearch. @@ -452,7 +454,7 @@ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions): const convoIndex = client.index('convos'); const deletedConvos = await mongoose .model('Conversation') - .find(conditions as mongoose.FilterQuery) + .find(conditions as FilterQuery) .lean(); const promises = deletedConvos.map((convo: Record) => convoIndex.deleteDocument(convo.conversationId as string), @@ -464,7 +466,7 @@ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions): const messageIndex = client.index('messages'); const deletedMessages = await mongoose .model('Message') - .find(conditions as mongoose.FilterQuery) + .find(conditions as FilterQuery) .lean(); const promises = deletedMessages.map((message: Record) => messageIndex.deleteDocument(message.messageId as string), diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index 8b0eb5b5c7..9680a49d9e 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -1,5 +1,4 @@ import { Schema } from 'mongoose'; -import mongoMeili from '~/models/plugins/mongoMeili'; import { conversationPreset } from './defaults'; import { IConversation } from '~/types'; @@ -48,14 +47,4 @@ convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); convoSchema.index({ createdAt: 1, updatedAt: 1 }); convoSchema.index({ conversationId: 1, user: 1 }, { unique: true }); -if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { - convoSchema.plugin(mongoMeili, { - host: process.env.MEILI_HOST, - apiKey: process.env.MEILI_MASTER_KEY, - /** Note: Will get created automatically if it doesn't exist already */ - indexName: 'convos', - primaryKey: 'conversationId', - }); -} - export default convoSchema; diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index 4946d1e449..15a80ae80e 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -1,6 +1,5 @@ import mongoose, { Schema } from 'mongoose'; import type { IMessage } from '~/types/message'; -import mongoMeili from '~/models/plugins/mongoMeili'; const messageSchema: Schema = new Schema( { @@ -166,13 +165,4 @@ messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); messageSchema.index({ createdAt: 1 }); messageSchema.index({ messageId: 1, user: 1 }, { unique: true }); -if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { - messageSchema.plugin(mongoMeili, { - host: process.env.MEILI_HOST, - apiKey: process.env.MEILI_MASTER_KEY, - indexName: 'messages', - primaryKey: 'messageId', - }); -} - export default messageSchema; From 53df6a1a71dfa558f653f890b466072781c31cbe Mon Sep 17 00:00:00 2001 From: matt burnett Date: Fri, 6 Jun 2025 16:43:39 -0400 Subject: [PATCH 11/49] =?UTF-8?q?=F0=9F=94=84=20fix:=20Update=20Agent=20Ve?= =?UTF-8?q?rsioning=20to=20Include=20`agent=5Fids`=20(#7762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Removed agent_ids exclusion from version comparison in the Agent model. * Added tests to ensure agent_ids changes trigger new version creation and handle duplicates correctly. * Enhanced existing tests to validate agent_ids alongside other fields and preserve history. --- api/models/Agent.js | 1 - api/models/Agent.spec.js | 172 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/api/models/Agent.js b/api/models/Agent.js index eb16e4d238..12b7a478af 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -170,7 +170,6 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul 'created_at', 'updated_at', '__v', - 'agent_ids', 'versions', 'actionsHash', // Exclude actionsHash from direct comparison ]; diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 08ece95fb9..0e35ef36a7 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -662,6 +662,7 @@ describe('models/Agent', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); }); @@ -1323,6 +1324,7 @@ describe('models/Agent', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); }); @@ -1504,6 +1506,7 @@ describe('models/Agent', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); }); @@ -1797,6 +1800,7 @@ describe('models/Agent', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); }); @@ -2344,6 +2348,174 @@ describe('models/Agent', () => { Agent.updateOne = originalUpdateOne; }); }); + + describe('Agent IDs Field in Version Detection', () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await Agent.deleteMany({}); + }); + + test('should now create new version when agent_ids field changes', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + const agent = await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + agent_ids: ['agent1', 'agent2'], + }); + + expect(agent).toBeDefined(); + expect(agent.versions).toHaveLength(1); + + const updated = await updateAgent( + { id: agentId }, + { agent_ids: ['agent1', 'agent2', 'agent3'] }, + ); + + // Since agent_ids is no longer excluded, this should create a new version + expect(updated.versions).toHaveLength(2); + expect(updated.agent_ids).toEqual(['agent1', 'agent2', 'agent3']); + }); + + test('should detect duplicate version if agent_ids is updated to same value', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + agent_ids: ['agent1', 'agent2'], + }); + + await updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2', 'agent3'] }); + + await expect( + updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2', 'agent3'] }), + ).rejects.toThrow('Duplicate version'); + }); + + test('should handle agent_ids field alongside other fields', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + description: 'Initial description', + agent_ids: ['agent1'], + }); + + const updated = await updateAgent( + { id: agentId }, + { + agent_ids: ['agent1', 'agent2'], + description: 'Updated description', + }, + ); + + expect(updated.versions).toHaveLength(2); + expect(updated.agent_ids).toEqual(['agent1', 'agent2']); + expect(updated.description).toBe('Updated description'); + + const updated2 = await updateAgent({ id: agentId }, { description: 'Another description' }); + + expect(updated2.versions).toHaveLength(3); + expect(updated2.agent_ids).toEqual(['agent1', 'agent2']); + expect(updated2.description).toBe('Another description'); + }); + + test('should preserve agent_ids in version history', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + agent_ids: ['agent1'], + }); + + await updateAgent({ id: agentId }, { agent_ids: ['agent1', 'agent2'] }); + + await updateAgent({ id: agentId }, { agent_ids: ['agent3'] }); + + const finalAgent = await getAgent({ id: agentId }); + + expect(finalAgent.versions).toHaveLength(3); + expect(finalAgent.versions[0].agent_ids).toEqual(['agent1']); + expect(finalAgent.versions[1].agent_ids).toEqual(['agent1', 'agent2']); + expect(finalAgent.versions[2].agent_ids).toEqual(['agent3']); + expect(finalAgent.agent_ids).toEqual(['agent3']); + }); + + test('should handle empty agent_ids arrays', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + agent_ids: ['agent1', 'agent2'], + }); + + const updated = await updateAgent({ id: agentId }, { agent_ids: [] }); + + expect(updated.versions).toHaveLength(2); + expect(updated.agent_ids).toEqual([]); + + await expect(updateAgent({ id: agentId }, { agent_ids: [] })).rejects.toThrow( + 'Duplicate version', + ); + }); + + test('should handle agent without agent_ids field', async () => { + const agentId = `agent_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + + const agent = await createAgent({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + }); + + expect(agent.agent_ids).toEqual([]); + + const updated = await updateAgent({ id: agentId }, { agent_ids: ['agent1'] }); + + expect(updated.versions).toHaveLength(2); + expect(updated.agent_ids).toEqual(['agent1']); + }); + }); }); function createBasicAgent(overrides = {}) { From 2c39ccd2af9633128decbfe93e5f5281cd587a20 Mon Sep 17 00:00:00 2001 From: Ben Verhees Date: Fri, 6 Jun 2025 23:29:17 +0200 Subject: [PATCH 12/49] =?UTF-8?q?=F0=9F=92=89=20feat:=20Optionally=20Injec?= =?UTF-8?q?t=20MCP=20Server=20Instructions=20(#7660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add MCP server instructions to context * chore: remove async method as no async code is performed Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: remove co-pilot promise resolution --------- Co-authored-by: Danny Avila Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/server/controllers/agents/client.js | 34 ++++++++- packages/data-provider/src/mcp.ts | 7 ++ packages/mcp/src/manager.ts | 95 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 31fd56930e..9631fe3801 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -32,11 +32,12 @@ const { getCustomEndpointConfig, checkCapability } = require('~/server/services/ const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { getBufferString, HumanMessage } = require('@langchain/core/messages'); +const { DynamicStructuredTool } = require('@langchain/core/tools'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); const Tokenizer = require('~/server/services/Tokenizer'); const BaseClient = require('~/app/clients/BaseClient'); -const { logger, sendEvent } = require('~/config'); +const { logger, sendEvent, getMCPManager } = require('~/config'); const { createRun } = require('./run'); /** @@ -370,6 +371,37 @@ class AgentClient extends BaseClient { systemContent = this.augmentedPrompt + systemContent; } + // Inject MCP server instructions if available + const ephemeralAgent = this.options.req.body.ephemeralAgent; + let mcpServers = []; + + // Check for ephemeral agent MCP servers + if (ephemeralAgent && ephemeralAgent.mcp && ephemeralAgent.mcp.length > 0) { + mcpServers = ephemeralAgent.mcp; + } + // Check for regular agent MCP tools + else if (this.options.agent && this.options.agent.tools) { + mcpServers = this.options.agent.tools + .filter( + (tool) => + tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter), + ) + .map((tool) => tool.name.split(Constants.mcp_delimiter).pop()) + .filter(Boolean); + } + + if (mcpServers.length > 0) { + try { + const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers); + if (mcpInstructions) { + systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n'); + logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers); + } + } catch (error) { + logger.error('[AgentClient] Failed to inject MCP instructions:', error); + } + } + if (systemContent) { this.options.agent.instructions = systemContent; } diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 258eb15ae3..1273274c54 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -7,6 +7,13 @@ const BaseOptionsSchema = z.object({ initTimeout: z.number().optional(), /** Controls visibility in chat dropdown menu (MCPSelect) */ chatMenu: z.boolean().optional(), + /** + * Controls server instruction behavior: + * - undefined/not set: No instructions included (default) + * - true: Use server-provided instructions + * - string: Use custom instructions (overrides server-provided) + */ + serverInstructions: z.union([z.boolean(), z.string()]).optional(), }); export const StdioOptionsSchema = BaseOptionsSchema.extend({ diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 8fe9074b8f..0d8a1e4653 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -22,6 +22,8 @@ export class MCPManager { private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable) private mcpConfigs: t.MCPServers = {}; private processMCPEnv?: (obj: MCPOptions, userId?: string) => MCPOptions; // Store the processing function + /** Store MCP server instructions */ + private serverInstructions: Map = new Map(); private logger: Logger; private static getDefaultLogger(): Logger { @@ -75,6 +77,42 @@ export class MCPManager { initializedServers.add(i); this.connections.set(serverName, connection); // Store in app-level map + // Handle unified serverInstructions configuration + const configInstructions = config.serverInstructions; + + if (configInstructions !== undefined) { + if (typeof configInstructions === 'string') { + // Custom instructions provided + this.serverInstructions.set(serverName, configInstructions); + this.logger.info( + `[MCP][${serverName}] Custom instructions stored for context inclusion: ${configInstructions}`, + ); + } else if (configInstructions === true) { + // Use server-provided instructions + const serverInstructions = connection.client.getInstructions(); + + if (serverInstructions) { + this.serverInstructions.set(serverName, serverInstructions); + this.logger.info( + `[MCP][${serverName}] Server instructions stored for context inclusion: ${serverInstructions}`, + ); + } else { + this.logger.info( + `[MCP][${serverName}] serverInstructions=true but no server instructions available`, + ); + } + } else { + // configInstructions is false - explicitly disabled + this.logger.info( + `[MCP][${serverName}] Instructions explicitly disabled (serverInstructions=false)`, + ); + } + } else { + this.logger.info( + `[MCP][${serverName}] Instructions not included (serverInstructions not configured)`, + ); + } + const serverCapabilities = connection.client.getServerCapabilities(); this.logger.info( `[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`, @@ -519,4 +557,61 @@ export class MCPManager { logger.info('[MCP] Manager instance destroyed.'); } } + + /** + * Get instructions for MCP servers + * @param serverNames Optional array of server names. If not provided or empty, returns all servers. + * @returns Object mapping server names to their instructions + */ + public getInstructions(serverNames?: string[]): Record { + const instructions: Record = {}; + + if (!serverNames || serverNames.length === 0) { + // Return all instructions if no specific servers requested + for (const [serverName, serverInstructions] of this.serverInstructions.entries()) { + instructions[serverName] = serverInstructions; + } + } else { + // Return instructions for specific servers + for (const serverName of serverNames) { + const serverInstructions = this.serverInstructions.get(serverName); + if (serverInstructions) { + instructions[serverName] = serverInstructions; + } + } + } + + return instructions; + } + + /** + * Format MCP server instructions for injection into context + * @param serverNames Optional array of server names to include. If not provided, includes all servers. + * @returns Formatted instructions string ready for context injection + */ + public formatInstructionsForContext(serverNames?: string[]): string { + /** Instructions for specified servers or all stored instructions */ + const instructionsToInclude = this.getInstructions(serverNames); + + if (Object.keys(instructionsToInclude).length === 0) { + return ''; + } + + // Format instructions for context injection + const formattedInstructions = Object.entries(instructionsToInclude) + .map(([serverName, instructions]) => { + return `## ${serverName} MCP Server Instructions + +${instructions}`; + }) + .join('\n\n'); + + return `# MCP Server Instructions + +The following MCP servers are available with their specific instructions: + +${formattedInstructions} + +Please follow these instructions when using tools from the respective MCP servers.`; + } } From c22d74d41e3254034613e8be154f5dd06e6b09b7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Jun 2025 00:49:41 -0400 Subject: [PATCH 13/49] fix: disable tracking clicks in Mailgun email configuration --- api/server/utils/sendEmail.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js index 42f99c78fc..3c1f189c94 100644 --- a/api/server/utils/sendEmail.js +++ b/api/server/utils/sendEmail.js @@ -34,6 +34,7 @@ const sendEmailViaMailgun = async ({ to, from, subject, html }) => { formData.append('to', to); formData.append('subject', subject); formData.append('html', html); + formData.append('o:tracking-clicks', 'no'); try { const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, { From cd7dd576c1f945bb731a4415a04bc1b702b6f4e4 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 8 Jun 2025 00:22:08 +0200 Subject: [PATCH 14/49] =?UTF-8?q?=F0=9F=8E=A8=20style:=20Unify=20Styles=20?= =?UTF-8?q?across=20Themes=20and=20Improve=20Accessibility=20(#7783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style: update button styles for improved hover effects and accessibility * style: enhance CustomMenuItem styling for improved visual feedback * style: improved accessibility and visual consistency * chore: add missing localization in ActionsPanel --------- Co-authored-by: Danny Avila --- .../Chat/Menus/Endpoints/CustomMenu.tsx | 2 +- .../Chat/Messages/Content/DialogImage.tsx | 18 ++++++------ .../Parts/OpenAIImageGen/OpenAIImageGen.tsx | 11 -------- .../src/components/Chat/Messages/Feedback.tsx | 18 ++++-------- client/src/components/Chat/Messages/Fork.tsx | 8 ++---- .../components/Chat/Messages/HoverButtons.tsx | 14 ++-------- .../SidePanel/Agents/ActionsPanel.tsx | 2 +- client/src/locales/en/translation.json | 3 +- client/src/style.css | 28 +++++++++---------- 9 files changed, 38 insertions(+), 66 deletions(-) diff --git a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx index d466698938..3563ae0a7b 100644 --- a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx +++ b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx @@ -159,7 +159,7 @@ export const CustomMenuItem = React.forwardRef
downloadImage()} variant="ghost" className="h-10 w-10 p-0"> @@ -108,7 +108,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm alt="Image" className="max-h-full max-w-full object-contain" style={{ - maxHeight: 'calc(100vh - 6rem)', // Account for header and padding + maxHeight: 'calc(100vh - 6rem)', maxWidth: '100%', }} /> @@ -117,7 +117,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm {/* Side Panel */}
@@ -132,7 +132,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
{/* Prompt Section */}
-

+

{localize('com_ui_prompt')}

@@ -144,20 +144,18 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm {/* Generation Settings */}
-

+

{localize('com_ui_generation_settings')}

- {localize('com_ui_size')}: + {localize('com_ui_size')}: {args?.size || 'Unknown'}
- - {localize('com_ui_quality')}: - + {localize('com_ui_quality')}:
- + {localize('com_ui_file_size')}: diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx index e13e68312d..ef24c3553e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx @@ -178,17 +178,6 @@ export default function OpenAIImageGen({
- - {/* {showInfo && hasInfo && ( - 0 && !cancelled && initialProgress < 1} - /> - )} */} -
{dimensions.width !== 'auto' && progress < 1 && ( diff --git a/client/src/components/Chat/Messages/Feedback.tsx b/client/src/components/Chat/Messages/Feedback.tsx index cf7ccadbab..4879808d90 100644 --- a/client/src/components/Chat/Messages/Feedback.tsx +++ b/client/src/components/Chat/Messages/Feedback.tsx @@ -216,18 +216,12 @@ function FeedbackButtons({ function buttonClasses(isActive: boolean, isLast: boolean) { return cn( - 'hover-button rounded-lg p-1.5', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white', - 'hover:bg-gray-100 hover:text-gray-500', - 'data-[state=open]:active data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500', - isActive ? 'text-gray-500 dark:text-gray-200 font-bold' : 'dark:text-gray-400/70', - 'dark:hover:bg-gray-700 dark:hover:text-gray-200', - 'data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200', - 'disabled:dark:hover:text-gray-400', - isLast - ? '' - : 'data-[state=open]:opacity-100 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100', - 'md:group-focus-within:visible md:group-hover:visible md:group-[.final-completion]:visible', + 'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200', + 'hover:text-text-primary hover:bg-surface-hover', + 'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible', + !isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100', + 'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none', + isActive && 'active text-text-primary bg-surface-hover', ); } diff --git a/client/src/components/Chat/Messages/Fork.tsx b/client/src/components/Chat/Messages/Fork.tsx index 5bc0bd8839..1cc319c3dd 100644 --- a/client/src/components/Chat/Messages/Fork.tsx +++ b/client/src/components/Chat/Messages/Fork.tsx @@ -211,14 +211,12 @@ export default function Fork({ }); const buttonStyle = cn( - 'hover-button rounded-lg p-1.5', - 'hover:bg-gray-100 hover:text-gray-500', - 'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200', - 'disabled:dark:hover:text-gray-400', + 'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200', + 'hover:text-text-primary hover:bg-surface-hover', 'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible', !isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100', 'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none', - isActive && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700', + isActive && 'active text-text-primary bg-surface-hover', ); const forkConvo = useForkConvoMutation({ diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx index 5783540bb8..644852c0b4 100644 --- a/client/src/components/Chat/Messages/HoverButtons.tsx +++ b/client/src/components/Chat/Messages/HoverButtons.tsx @@ -77,21 +77,13 @@ const HoverButton = memo( className = '', }: HoverButtonProps) => { const buttonStyle = cn( - 'hover-button rounded-lg p-1.5', - - 'hover:bg-gray-100 hover:text-gray-500', - - 'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200', - 'disabled:dark:hover:text-gray-400', - + 'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200', + 'hover:text-text-primary hover:bg-surface-hover', 'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible', !isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100', !isVisible && 'opacity-0', - 'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none', - - isActive && isVisible && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700', - + isActive && isVisible && 'active text-text-primary bg-surface-hover', className, ); diff --git a/client/src/components/SidePanel/Agents/ActionsPanel.tsx b/client/src/components/SidePanel/Agents/ActionsPanel.tsx index 514e1b61eb..0ae564b05a 100644 --- a/client/src/components/SidePanel/Agents/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Agents/ActionsPanel.tsx @@ -128,7 +128,7 @@ export default function ActionsPanel({ selectHandler: () => { if (!agent_id) { return showToast({ - message: 'No agent_id found, is the agent created?', + message: localize('com_agents_no_agent_id_error'), status: 'error', }); } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 52c190f1f8..284117ffe4 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -20,6 +20,7 @@ "com_agents_missing_provider_model": "Please select a provider and model before creating an agent.", "com_agents_name_placeholder": "Optional: The name of the agent", "com_agents_no_access": "You don't have access to edit this agent.", + "com_agents_no_agent_id_error": "No agent ID found. Please ensure the agent is created first.", "com_agents_not_available": "Agent Not Available", "com_agents_search_info": "When enabled, allows your agent to search the web for up-to-date information. Requires a valid API key.", "com_agents_search_name": "Search agents by name", @@ -970,4 +971,4 @@ "com_ui_zoom": "Zoom", "com_user_message": "You", "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." -} \ No newline at end of file +} diff --git a/client/src/style.css b/client/src/style.css index a91b1b49d2..438f197a55 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2720,6 +2720,20 @@ html { .shimmer { display: inline-block; position: relative; + background: linear-gradient( + 90deg, + rgb(33, 33, 33) 25%, + rgba(129, 130, 134, 0.18) 50%, + rgb(33, 33, 33) 75% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 4s linear infinite; +} + +.dark .shimmer { background: linear-gradient( 90deg, rgba(255, 255, 255, 0.8) 25%, @@ -2733,20 +2747,6 @@ html { animation: shimmer 4s linear infinite; } -:global(.dark) .shimmer { - background: linear-gradient( - 90deg, - rgba(255, 255, 255) 25%, - rgba(129, 130, 134, 0.18) 50%, - rgb(255, 255, 255) 75% - ); - background-size: 200% 100%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: shimmer 4s linear infinite; -} - .custom-style-2 { padding: 12px; } From 29ef91b4dd53d500c7d5536e37ad6072514099fc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 7 Jun 2025 18:52:22 -0400 Subject: [PATCH 15/49] =?UTF-8?q?=F0=9F=A7=A0=20feat:=20User=20Memories=20?= =?UTF-8?q?for=20Conversational=20Context=20(#7760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧠 feat: User Memories for Conversational Context chore: mcp typing, use `t` WIP: first pass, Memories UI - Added MemoryViewer component for displaying, editing, and deleting user memories. - Integrated data provider hooks for fetching, updating, and deleting memories. - Implemented pagination and loading states for better user experience. - Created unit tests for MemoryViewer to ensure functionality and interaction with data provider. - Updated translation files to include new UI strings related to memories. chore: move mcp-related files to own directory chore: rename librechat-mcp to librechat-api WIP: first pass, memory processing and data schemas chore: linting in fileSearch.js query description chore: rename librechat-api to @librechat/api across the project WIP: first pass, functional memory agent feat: add MemoryEditDialog and MemoryViewer components for managing user memories - Introduced MemoryEditDialog for editing memory entries with validation and toast notifications. - Updated MemoryViewer to support editing and deleting memories, including pagination and loading states. - Enhanced data provider to handle memory updates with optional original key for better management. - Added new localization strings for memory-related UI elements. feat: add memory permissions management - Implemented memory permissions in the backend, allowing roles to have specific permissions for using, creating, updating, and reading memories. - Added new API endpoints for updating memory permissions associated with roles. - Created a new AdminSettings component for managing memory permissions in the frontend. - Integrated memory permissions into the existing roles and permissions schemas. - Updated the interface to include memory settings and permissions. - Enhanced the MemoryViewer component to conditionally render admin settings based on user roles. - Added localization support for memory permissions in the translation files. feat: move AdminSettings component to a new position in MemoryViewer for better visibility refactor: clean up commented code in MemoryViewer component feat: enhance MemoryViewer with search functionality and improve MemoryEditDialog integration - Added a search input to filter memories in the MemoryViewer component. - Refactored MemoryEditDialog to accept children for better customization. - Updated MemoryViewer to utilize the new EditMemoryButton and DeleteMemoryButton components for editing and deleting memories. - Improved localization support by adding new strings for memory filtering and deletion confirmation. refactor: optimize memory filtering in MemoryViewer using match-sorter - Replaced manual filtering logic with match-sorter for improved search functionality. - Enhanced performance and readability of the filteredMemories computation. feat: enhance MemoryEditDialog with triggerRef and improve updateMemory mutation handling feat: implement access control for MemoryEditDialog and MemoryViewer components refactor: remove commented out code and create runMemory method refactor: rename role based files feat: implement access control for memory usage in AgentClient refactor: simplify checkVisionRequest method in AgentClient by removing commented-out code refactor: make `agents` dir in api package refactor: migrate Azure utilities to TypeScript and consolidate imports refactor: move sanitizeFilename function to a new file and update imports, add related tests refactor: update LLM configuration types and consolidate Azure options in the API package chore: linting chore: import order refactor: replace getLLMConfig with getOpenAIConfig and remove unused LLM configuration file chore: update winston-daily-rotate-file to version 5.0.0 and add object-hash dependency in package-lock.json refactor: move primeResources and optionalChainWithEmptyCheck functions to resources.ts and update imports refactor: move createRun function to a new run.ts file and update related imports fix: ensure safeAttachments is correctly typed as an array of TFile chore: add node-fetch dependency and refactor fetch-related functions into packages/api/utils, removing the old generators file refactor: enhance TEndpointOption type by using Pick to streamline endpoint fields and add new properties for model parameters and client options feat: implement initializeOpenAIOptions function and update OpenAI types for enhanced configuration handling fix: update types due to new TEndpointOption typing fix: ensure safe access to group parameters in initializeOpenAIOptions function fix: remove redundant API key validation comment in initializeOpenAIOptions function refactor: rename initializeOpenAIOptions to initializeOpenAI for consistency and update related documentation refactor: decouple req.body fields and tool loading from initializeAgentOptions chore: linting refactor: adjust column widths in MemoryViewer for improved layout refactor: simplify agent initialization by creating loadAgent function and removing unused code feat: add memory configuration loading and validation functions WIP: first pass, memory processing with config feat: implement memory callback and artifact handling feat: implement memory artifacts display and processing updates feat: add memory configuration options and schema validation for validKeys fix: update MemoryEditDialog and MemoryViewer to handle memory state and display improvements refactor: remove padding from BookmarkTable and MemoryViewer headers for consistent styling WIP: initial tokenLimit config and move Tokenizer to @librechat/api refactor: update mongoMeili plugin methods to use callback for better error handling feat: enhance memory management with token tracking and usage metrics - Added token counting for memory entries to enforce limits and provide usage statistics. - Updated memory retrieval and update routes to include total token usage and limit. - Enhanced MemoryEditDialog and MemoryViewer components to display memory usage and token information. - Refactored memory processing functions to handle token limits and provide feedback on memory capacity. feat: implement memory artifact handling in attachment handler - Enhanced useAttachmentHandler to process memory artifacts when receiving updates. - Introduced handleMemoryArtifact utility to manage memory updates and deletions. - Updated query client to reflect changes in memory state based on incoming data. refactor: restructure web search key extraction logic - Moved the logic for extracting API keys from the webSearchAuth configuration into a dedicated function, getWebSearchKeys. - Updated webSearchKeys to utilize the new function for improved clarity and maintainability. - Prevents build time errors feat: add personalization settings and memory preferences management - Introduced a new Personalization tab in settings to manage user memory preferences. - Implemented API endpoints and client-side logic for updating memory preferences. - Enhanced user interface components to reflect personalization options and memory usage. - Updated permissions to allow users to opt out of memory features. - Added localization support for new settings and messages related to personalization. style: personalization switch class feat: add PersonalizationIcon and align Side Panel UI feat: implement memory creation functionality - Added a new API endpoint for creating memory entries, including validation for key and value. - Introduced MemoryCreateDialog component for user interface to facilitate memory creation. - Integrated token limit checks to prevent exceeding user memory capacity. - Updated MemoryViewer to include a button for opening the memory creation dialog. - Enhanced localization support for new messages related to memory creation. feat: enhance message processing with configurable window size - Updated AgentClient to use a configurable message window size for processing messages. - Introduced messageWindowSize option in memory configuration schema with a default value of 5. - Improved logic for selecting messages to process based on the configured window size. chore: update librechat-data-provider version to 0.7.87 in package.json and package-lock.json chore: remove OpenAPIPlugin and its associated tests chore: remove MIGRATION_README.md as migration tasks are completed ci: fix backend tests chore: remove unused translation keys from localization file chore: remove problematic test file and unused var in AgentClient chore: remove unused import and import directly for JSDoc * feat: add api package build stage in Dockerfile for improved modularity * docs: reorder build steps in contributing guide for clarity --- .github/CONTRIBUTING.md | 4 +- .github/workflows/backend-review.yml | 11 +- Dockerfile.multi | 23 +- api/app/clients/AnthropicClient.js | 3 +- api/app/clients/ChatGPTClient.js | 3 +- api/app/clients/GoogleClient.js | 2 +- api/app/clients/OpenAIClient.js | 20 +- api/app/clients/generators.js | 71 -- api/app/clients/llm/createLLM.js | 3 +- api/app/clients/specs/BaseClient.test.js | 2 + .../clients/tools/dynamic/OpenAPIPlugin.js | 184 ----- .../tools/dynamic/OpenAPIPlugin.spec.js | 72 -- api/app/clients/tools/structured/DALLE3.js | 4 +- .../tools/structured/specs/DALLE3.spec.js | 28 +- api/app/clients/tools/util/fileSearch.js | 2 +- api/config/index.js | 4 +- api/package.json | 5 +- api/server/cleanup.js | 3 + api/server/controllers/agents/callbacks.js | 5 +- api/server/controllers/agents/client.js | 357 ++++----- api/server/controllers/agents/run.js | 94 --- api/server/index.js | 2 +- .../{generateCheckAccess.js => access.js} | 0 .../roles/{checkAdmin.js => admin.js} | 0 api/server/middleware/roles/index.js | 4 +- api/server/routes/files/multer.js | 2 +- api/server/routes/index.js | 2 + api/server/routes/memories.js | 231 ++++++ api/server/routes/roles.js | 40 + api/server/services/ActionService.js | 4 +- api/server/services/AppService.js | 3 + api/server/services/Endpoints/agents/agent.js | 196 +++++ .../services/Endpoints/agents/initialize.js | 327 ++------ .../Endpoints/azureAssistants/initialize.js | 3 +- .../services/Endpoints/bedrock/options.js | 2 +- .../services/Endpoints/custom/initialize.js | 5 +- .../Endpoints/gptPlugins/initialize.js | 5 +- .../Endpoints/gptPlugins/initialize.spec.js | 10 +- .../services/Endpoints/openAI/initialize.js | 13 +- api/server/services/Endpoints/openAI/llm.js | 170 ----- api/server/services/Files/Audio/STTService.js | 2 +- api/server/services/Files/Audio/TTSService.js | 2 +- api/server/services/MCP.js | 2 +- api/server/services/start/interface.js | 18 + api/server/services/start/interface.spec.js | 20 + api/server/utils/handleText.js | 35 - api/server/utils/handleText.spec.js | 103 --- api/typedefs.js | 14 +- api/utils/index.js | 2 - .../Chat/Messages/Content/ContentParts.tsx | 3 + .../Chat/Messages/Content/MemoryArtifacts.tsx | 143 ++++ .../Chat/Messages/Content/MemoryInfo.tsx | 61 ++ client/src/components/Nav/Settings.tsx | 49 +- .../Nav/SettingsTabs/Personalization.tsx | 87 +++ .../src/components/Nav/SettingsTabs/index.ts | 1 + .../Prompts/Groups/FilterPrompts.tsx | 10 +- .../Prompts/Groups/GroupSidePanel.tsx | 20 +- .../Prompts/Groups/PanelNavigation.tsx | 14 +- .../components/Prompts/PromptsAccordion.tsx | 8 +- .../SidePanel/Bookmarks/BookmarkTable.tsx | 6 +- .../SidePanel/Memories/AdminSettings.tsx | 212 ++++++ .../SidePanel/Memories/MemoryCreateDialog.tsx | 147 ++++ .../SidePanel/Memories/MemoryEditDialog.tsx | 179 +++++ .../SidePanel/Memories/MemoryViewer.tsx | 428 +++++++++++ .../components/SidePanel/Memories/index.ts | 2 + .../components/svg/PersonalizationIcon.tsx | 19 + client/src/components/svg/index.ts | 1 + client/src/components/ui/OriginalDialog.tsx | 2 +- client/src/components/ui/Table.tsx | 8 +- client/src/data-provider/Memories/index.ts | 2 + client/src/data-provider/Memories/queries.ts | 116 +++ .../data-provider/__tests__/memories.test.ts | 19 + client/src/data-provider/index.ts | 2 + client/src/data-provider/roles.ts | 45 +- client/src/hooks/Nav/useSideNavLinks.ts | 23 +- client/src/hooks/SSE/useAttachmentHandler.ts | 17 +- client/src/hooks/usePersonalizationAccess.ts | 16 + client/src/locales/en/translation.json | 39 +- client/src/utils/memory.ts | 90 +++ config/packages.js | 4 +- config/update.js | 8 +- eslint.config.mjs | 12 +- librechat.example.yaml | 22 + package-lock.json | 381 +++++----- package.json | 6 +- packages/{mcp => api}/.gitignore | 0 packages/{mcp => api}/babel.config.cjs | 0 packages/{mcp => api}/jest.config.mjs | 0 packages/{mcp => api}/package.json | 17 +- packages/{mcp => api}/rollup.config.js | 0 packages/api/src/agents/index.ts | 3 + packages/api/src/agents/memory.ts | 468 ++++++++++++ packages/api/src/agents/resources.test.ts | 543 ++++++++++++++ packages/api/src/agents/resources.ts | 114 +++ packages/api/src/agents/run.ts | 90 +++ packages/api/src/endpoints/index.ts | 1 + packages/api/src/endpoints/openai/index.ts | 2 + .../api/src/endpoints/openai/initialize.ts | 176 +++++ packages/api/src/endpoints/openai/llm.ts | 156 ++++ .../{mcp => api}/src/flow/manager.spec.ts | 0 packages/{mcp => api}/src/flow/manager.ts | 0 packages/{mcp => api}/src/flow/types.ts | 0 packages/api/src/index.ts | 14 + .../{mcp/src => api/src/mcp}/connection.ts | 4 +- packages/{mcp/src => api/src/mcp}/enum.ts | 0 packages/{mcp/src => api/src/mcp}/manager.ts | 2 +- packages/{mcp/src => api/src/mcp}/parsers.ts | 2 +- .../mcp.ts => api/src/mcp/types/index.ts} | 5 +- .../{mcp/src => api/src/mcp}/utils.test.ts | 0 packages/{mcp/src => api/src/mcp}/utils.ts | 0 packages/api/src/types/azure.ts | 19 + packages/api/src/types/events.ts | 4 + packages/api/src/types/index.ts | 4 + packages/api/src/types/openai.ts | 97 +++ packages/api/src/types/run.ts | 10 + .../api/src/utils/azure.spec.ts | 15 +- .../api/src/utils/azure.ts | 99 +-- packages/api/src/utils/common.spec.ts | 55 ++ packages/api/src/utils/common.ts | 48 ++ packages/api/src/utils/events.ts | 16 + packages/api/src/utils/files.spec.ts | 115 +++ packages/api/src/utils/files.ts | 33 + packages/api/src/utils/generators.ts | 75 ++ packages/api/src/utils/index.ts | 5 + .../api/src/utils/tokenizer.spec.ts | 21 +- .../api/src/utils/tokenizer.ts | 34 +- .../{mcp => api}/tsconfig-paths-bootstrap.mjs | 0 packages/{mcp => api}/tsconfig.json | 5 +- packages/{mcp => api}/tsconfig.spec.json | 0 packages/data-provider/package.json | 2 +- packages/data-provider/src/api-endpoints.ts | 6 + packages/data-provider/src/config.ts | 30 + packages/data-provider/src/createPayload.ts | 5 +- packages/data-provider/src/data-service.ts | 36 + packages/data-provider/src/index.ts | 2 + packages/data-provider/src/keys.ts | 3 + packages/data-provider/src/memory.ts | 62 ++ packages/data-provider/src/parsers.ts | 4 +- packages/data-provider/src/permissions.ts | 16 + packages/data-provider/src/roles.ts | 16 + packages/data-provider/src/schemas.ts | 8 + packages/data-provider/src/types.ts | 98 ++- .../data-provider/src/types/assistants.ts | 1 + packages/data-provider/src/types/mutations.ts | 9 +- packages/data-provider/src/types/queries.ts | 15 + packages/data-provider/src/web.ts | 28 +- packages/data-schemas/README.md | 114 --- packages/data-schemas/src/index.ts | 1 + packages/data-schemas/src/methods/index.ts | 6 +- packages/data-schemas/src/methods/memory.ts | 168 +++++ packages/data-schemas/src/methods/user.ts | 30 + packages/data-schemas/src/models/index.ts | 2 + packages/data-schemas/src/models/memory.ts | 6 + .../src/models/plugins/mongoMeili.ts | 125 +++- packages/data-schemas/src/schema/index.ts | 1 + packages/data-schemas/src/schema/memory.ts | 33 + packages/data-schemas/src/schema/role.ts | 13 + packages/data-schemas/src/schema/user.ts | 9 + packages/data-schemas/src/types/index.ts | 5 + packages/data-schemas/src/types/memory.ts | 48 ++ packages/data-schemas/src/types/role.ts | 6 + packages/data-schemas/src/types/user.ts | 3 + packages/mcp/src/demo/everything.ts | 231 ------ packages/mcp/src/demo/filesystem.ts | 211 ------ packages/mcp/src/demo/servers.ts | 226 ------ .../mcp/src/examples/everything/everything.ts | 426 ----------- packages/mcp/src/examples/everything/index.ts | 23 - packages/mcp/src/examples/everything/sse.ts | 24 - packages/mcp/src/examples/filesystem.ts | 700 ------------------ packages/mcp/src/index.ts | 9 - 170 files changed, 5700 insertions(+), 3632 deletions(-) delete mode 100644 api/app/clients/generators.js delete mode 100644 api/app/clients/tools/dynamic/OpenAPIPlugin.js delete mode 100644 api/app/clients/tools/dynamic/OpenAPIPlugin.spec.js delete mode 100644 api/server/controllers/agents/run.js rename api/server/middleware/roles/{generateCheckAccess.js => access.js} (100%) rename api/server/middleware/roles/{checkAdmin.js => admin.js} (100%) create mode 100644 api/server/routes/memories.js create mode 100644 api/server/services/Endpoints/agents/agent.js delete mode 100644 api/server/services/Endpoints/openAI/llm.js delete mode 100644 api/server/utils/handleText.spec.js create mode 100644 client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx create mode 100644 client/src/components/Chat/Messages/Content/MemoryInfo.tsx create mode 100644 client/src/components/Nav/SettingsTabs/Personalization.tsx create mode 100644 client/src/components/SidePanel/Memories/AdminSettings.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryEditDialog.tsx create mode 100644 client/src/components/SidePanel/Memories/MemoryViewer.tsx create mode 100644 client/src/components/SidePanel/Memories/index.ts create mode 100644 client/src/components/svg/PersonalizationIcon.tsx create mode 100644 client/src/data-provider/Memories/index.ts create mode 100644 client/src/data-provider/Memories/queries.ts create mode 100644 client/src/data-provider/__tests__/memories.test.ts create mode 100644 client/src/hooks/usePersonalizationAccess.ts create mode 100644 client/src/utils/memory.ts rename packages/{mcp => api}/.gitignore (100%) rename packages/{mcp => api}/babel.config.cjs (100%) rename packages/{mcp => api}/jest.config.mjs (100%) rename packages/{mcp => api}/package.json (90%) rename packages/{mcp => api}/rollup.config.js (100%) create mode 100644 packages/api/src/agents/index.ts create mode 100644 packages/api/src/agents/memory.ts create mode 100644 packages/api/src/agents/resources.test.ts create mode 100644 packages/api/src/agents/resources.ts create mode 100644 packages/api/src/agents/run.ts create mode 100644 packages/api/src/endpoints/index.ts create mode 100644 packages/api/src/endpoints/openai/index.ts create mode 100644 packages/api/src/endpoints/openai/initialize.ts create mode 100644 packages/api/src/endpoints/openai/llm.ts rename packages/{mcp => api}/src/flow/manager.spec.ts (100%) rename packages/{mcp => api}/src/flow/manager.ts (100%) rename packages/{mcp => api}/src/flow/types.ts (100%) create mode 100644 packages/api/src/index.ts rename packages/{mcp/src => api/src/mcp}/connection.ts (99%) rename packages/{mcp/src => api/src/mcp}/enum.ts (100%) rename packages/{mcp/src => api/src/mcp}/manager.ts (99%) rename packages/{mcp/src => api/src/mcp}/parsers.ts (99%) rename packages/{mcp/src/types/mcp.ts => api/src/mcp/types/index.ts} (92%) rename packages/{mcp/src => api/src/mcp}/utils.test.ts (100%) rename packages/{mcp/src => api/src/mcp}/utils.ts (100%) create mode 100644 packages/api/src/types/azure.ts create mode 100644 packages/api/src/types/events.ts create mode 100644 packages/api/src/types/index.ts create mode 100644 packages/api/src/types/openai.ts create mode 100644 packages/api/src/types/run.ts rename api/utils/azureUtils.spec.js => packages/api/src/utils/azure.spec.ts (97%) rename api/utils/azureUtils.js => packages/api/src/utils/azure.ts (50%) create mode 100644 packages/api/src/utils/common.spec.ts create mode 100644 packages/api/src/utils/common.ts create mode 100644 packages/api/src/utils/events.ts create mode 100644 packages/api/src/utils/files.spec.ts create mode 100644 packages/api/src/utils/files.ts create mode 100644 packages/api/src/utils/generators.ts create mode 100644 packages/api/src/utils/index.ts rename api/server/services/Tokenizer.spec.js => packages/api/src/utils/tokenizer.spec.ts (90%) rename api/server/services/Tokenizer.js => packages/api/src/utils/tokenizer.ts (56%) rename packages/{mcp => api}/tsconfig-paths-bootstrap.mjs (100%) rename packages/{mcp => api}/tsconfig.json (92%) rename packages/{mcp => api}/tsconfig.spec.json (100%) create mode 100644 packages/data-provider/src/memory.ts delete mode 100644 packages/data-schemas/README.md create mode 100644 packages/data-schemas/src/methods/memory.ts create mode 100644 packages/data-schemas/src/models/memory.ts create mode 100644 packages/data-schemas/src/schema/memory.ts create mode 100644 packages/data-schemas/src/types/memory.ts delete mode 100644 packages/mcp/src/demo/everything.ts delete mode 100644 packages/mcp/src/demo/filesystem.ts delete mode 100644 packages/mcp/src/demo/servers.ts delete mode 100644 packages/mcp/src/examples/everything/everything.ts delete mode 100644 packages/mcp/src/examples/everything/index.ts delete mode 100644 packages/mcp/src/examples/everything/sse.ts delete mode 100644 packages/mcp/src/examples/filesystem.ts delete mode 100644 packages/mcp/src/index.ts 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" + /> +
+
+ +